Appearance
Release 33
June 18th, 2025
Appearance
June 18th, 2025
The experiment configuration modal has been redesigned and experiments now support scheduling, allowing you to configure when to start and end an experiment.
One of the primary ways of interacting with the Metaplay SDK from your game's C# code has always been the ability to declare game-specific implementations of SDK base classes and interfaces, and to provide additional customization via declarations tagged with specific attributes. This is how you declare the contents of your game-specific Models and Actions, implement custom logic for Activables or LiveOps Events, configure your GameConfig build pipelines -- just to name a few examples.
To keep the amount of boilerplate at minimum and to make using the SDK feel fun and easy the SDK generally automatically discovers and interprets your integrations and customizations. For example to add a new Player Action to your game logic you simply create a new class that derives from PlayerActionBase
in your shared code assembly and the SDK will automatically discover it and add it to the set of allowed shared code actions, validate that it has been set up correctly and resolve any additional configuration from attributes -- such as DevelopmentOnlyAction
that would make the action legal only in development environments and for players marked as developers.
Up until now the process of discovering and interpreting integration types has been carried out as part of SDK initialization using runtime reflection. To do this, the SDK has had to scan through all assemblies that potentially contain integration types and use C# reflection APIs to discover details about them. This has the obvious downsides of both being a potentially slow process but also not being able to statically know what types are actually needed during the build, all while in practice all of the required information is already present when the code is being compiled!
In R33 we are introducing the use of Roslyn Incremental Source Generators to replace the use of runtime reflection in the SDK initialization. The Metaplay Source generators implemented in the Metaplay.CodeAnalyzers.Shared
project will get run whenever game assemblies are compiled both by Unity Editor and the standalone dotnet builds and will produce per-assembly integration information right into the assembly that is being compiled.
The goal is to ultimately replace all use of init-time runtime reflection in favour of source generators, including the generation of the binary serialization code. In this release we are taking a big step towards this goal by setting up the framework for source generators and migrating some of the most expensive runtime initialization steps, but the work will continue over the course of the next few SDK releases.
We've done everything in our power to make this transition transparent and seamless to integrations, but a few sacrifices had to be made. Unavoidable API changes are documented in the migration guide below, as usual.
Metaplay.CodeAnalyzers.Shared.Generator
.game_entity_crashed_total
with the entity
, and exception
labels (EntityKind and Exception type respectively).DatabaseScanProcessor.ShouldStop
property to allow stopping database scan before processing all items.PlayerActorBase.DetachAuthenticationMethodAsync
to allow programmatically detaching authentication methods of the player.goodbye
message is now an InternalEntitySessionGoodbyeMessage
containing information why the entity was unsubscribed.dotnet_memory_max_working_set
Prometheus metric for observing memory limits.PlayerActorBase.SendToClient()
, for consistency with MultiplayerEntityActorBase
.PlayerActorBase
and MultiplayerEntityActorBase
virtual methods for OnBeforeTick()
, OnAfterTick()
, OnBeforeAction()
and OnAfterAction()
.LeagueStateProxyActor
that proxies season state from leagues to all nodes. You can access the season state by calling LeagueStateProxyActor.GetSeasonState()
.MetaplayCore.InitializeForExternalApp()
helper method to initialize SDK on an external custom helper application.entrypoint
binary.IPlayerModel.IsBanned
and IPlayerModel.IsPermaBanned
in favor of IPlayerModel.BanInfo
.MetaplayCoreEditor.EnsureInitialized()
and added MetaplayCoreEditor.RunAfterMetaplayEditorInit()
for InitializeOnLoad
logic that requires MetaplayCore.Dictionary
and HashSet
types are no longer allowed by default in [MetaSerializable]
type, due to their nondeterministic iteration order.AuthenticationType.JWT
. It was only used to help with the transition from R31 to R32.AdminApi
and PublicWebApi
no longer enable Razor pages by default.OfflineServer.OnSessionStart()
now takes in bool isFirstLogin
, similarly to the PlayerActor
.MetaplaySDK.OnApplicationAboutToBePaused()
now allows pausing to happen up to 5 seconds after the call. Previously the hint was ignored if application did not pause within 1 second of the call.EntityAskAsync()
will now throw EntityUnreachableError
in target entity cannot be reached. Previously, the call would throw TimeoutException
after the timeout.EnumerateInstanceDataMembersInUnspecifiedOrder
no longer returns members marked by IgnoreDataMemberAttribute
.ConfigArchiveBuildUtility.ReadArchiveHeader()
now returns a struct instead of a tuple.AnalyticsSinkBigQuery:AddContextFieldsAsExtraEventParams
has been removed and is now always assumed to be true
.MultiplayerEntityActorBase
, renamed SendToClientEntity()
to SendToClient()
and SendToAllClientEntities()
to SendToAllClients()
.PlayerActorBase
, renamed TryGetOwnerSession()
to TryGetSession()
.System:StaticGameConfigPath
, System:StaticLocalizationsPath
and System:StaticLocalizationsIsFolder
have been removed. The initial boot-time GameConfigs and Localizations can now be configured by implementing a custom ServerInitialGameConfigProvider
.roots
that contains the generated IntegrationAssembly
instances for the "root" assemblies, i.e. assemblies that are not referred to by other assemblies containing Metaplay integration types. IntegrationAssembly.FindRoots()
can be used in cases where reflection-based discovery of generated information is available.AssemblyCSharpEditor
assembly.MetaplayCore.ClientIntegrationAssembly
in the generated code for AssemblyCSharp
.IntegrationRegistry
initialization, the per-assembly integration registry type info is now generated in the build and the runtime initialization of the integration registry merges the per-assembly generated data.UnityWebRequestAsyncOperation
awaitable as it conflicted with AsyncOperation
extensions.MultiplayerEntityActorBase.OnPostTick()
in favor of MultiplayerEntityActorBase.OnAfterTick()
.allowSynchronousExecution
argument from PlayerActorBase.EnqueueServerAction()
.PersistedMultiplayerEntityActorBase.MigrateState()
to protected so that it can be used by custom model importing code.MessageDispatcher
listeners for SessionProtocol.SessionStartSuccess
and other handshake messages are now called after IMetaplayLifecycleDelegate.OnSessionStarted(Async)()
has completed.AllowEntityCreationOnMessage
bool to return true
to allow this.AutoSyncSeasonSchedule
bool to return false
.Application.LoadLocalAssemblies()
into AssemblyUtil.LoadLocalAssemblies()
.NullReferenceException
on client Mono builds if connection fails, due to Unity stripping WebRequest.CreateHttp
internals.FileUtil.ReadAllBytesAsync
now throws IOException
when accessing a http-path that results in a 404. Previously the error message contents were returned.SQLite Error 5
errors when running a local server.Metaplay.InternalWatchdogDeadlineExceeded
incidents.IPlayerModelClientListenerCore.InAppPurchaseValidationFailed(InAppPurchaseEvent)
merely to call IAPManager.InAppPurchaseValidationFailed(...)
. The SDK now handles this internally, automatically routing the purchase failure information to IAPFlowTracker
and other to users of the IAPManager.OnPurchaseFinishedInMetaplay
event.KeyValuePair<K, V>
are now JSON serializable.Unknown
instead of UnityEditor
.PlayerModel
are now run with the Experiment changes applied in GameConfig
. Previously migrations were run with the Baseline config.MetaplayGuildClient.DiscoverGuilds()
and SearchGuilds()
throwing ArgumentOutOfRangeException
if session had ended.ExecuteOnActorContextAsync()
throwing ArgumentOutOfRangeException
if actor had shut down.GlobalStateProxy
failed to subscribe to GlobalStateManager
with EntityUnreachableError
.UseForwardedHeaders
middleware used by PublicWebApi as it is redundant and will cause redirect URIs to be generated with wrong protocol after the .NET June 2025 update.serverStatusHint.json
(maintenance mode state) to not be affected by aggressive code stripping settings in Unity.IncidentDashboardInfo
which contains all the members sent from the backend to the frontend for Player Incident Reports. It also contains a member called ExtraInfo
that is automatically visualized by the generated UI system. Use custom incident report types and the ExtraInfo
member to easily visualize custom members of incident reports. Find out more: Implementing Custom Incident Reports.<PlatformName>:<PlatformUserId>
where PlatformName
and PlatformUserId
are as shown in Player's Login Methods in Dashboard. For example searching FacebookLogin:1234567890
finds the user with the Facebook account attached.getGameDataByLibrary(libraryNames: string[])
and getGameDataByLibrarySubscriptionOptions(libraryNames: string[])
API function to fetch a subset of the game config libraries.Environment Detail
view to clearly indicate when the server last started.MInputDate
is now keyboard accessible; it can be selected with the Tab key and dates can be navigated using the arrow keys.MInputCheckbox
now shows clearer visual feedback for the different mouse states. It's internals were refactored with additional protection against edge cases such as programmatically toggling disabled checkboxes.MInputTime
now visualizes and handles out-of-range time values more gracefully.MInputSwitch
now visualizes tab selection with an outline like other input components.MInputDuration
now visualizes and handles unexpected-but-technically-correct inputs like 1.45 days more gracefully.MInputSingleFile
body element now has hover, drag & drop and active states to make it more obviously interactive.MInputSingleSelectDropdown
have more graceful fallback for conflicting selection vs options list values.MInputSingleSelectAsyncDropdown
is now clearable using an "x" button.MInputSingleSelectAsyncDropdown
now has a min-height
prop to ensure the input field has a consistent height even when no value is selected.MInputSingleSelectAsyncDropdown
now has clearer loading states for both initial and new results.badge
slot from the MCallout
component and replaced it with badgeText
and badgeVariant
properties instead.MCard
loading state now displays the badge
and header-right
slots for more information during loading.Player Incidents
, Broadcasts
, and Notifications
detail views have been split into tabs, making them easier to navigate and find information.getGameData()
API function has been deprecated in favour of the getGameDataByLibrary(libraryNames: string[])
and getGameDataByLibrarySubscriptionOptions(libraryNames: string[])
API functions. The new functions fetch only the game config libraries you need, improving page load times and overall performance.disabled
prop in MInputSwitch
is deprecated. To disable the component, use disabledTooltip
prop and provide a message explaining the reason.MRawDataCard
component now properly handles values that are not objects or arrays.Push Notifications
edit modal were sometimes incorrectly shown as invalid.error
values were ignored when client code subscribed to existing subscriptions.MInputNumber
where the clear button was not emitting undefined
despite the visual state being correct.MInputNumber
where the clear button remained enabled when the component was in a disabled state.MInputDurationOrEndDateTime
where the label was not hidden when the label
prop was empty.MInputDurationOrEndDateTime
where editing the duration would incorrectly clamp the selected duration into a rounded end time, causing decimals to be added to the minute selection.MInputSingleSelectRadio
were not displayed in the correct horizontal location.MInputSingleSelectRadio
component was disabled.MInputDuration
where the duration fields were sometimes incorrectly re-balanced during user input.MInputDuration
and MInputDurationOrEndDateTime
.MInputSingleFile
where the component would not properly react to programmatic changes in the modelValue
prop.MInputDate
where the min and max dates were not correctly inclusive of the current date when set to utcNow
.MPageOverviewCard
layout issue where long title text would break the adjacent badge layout.MInputSingleSelectDropdown
and MInputSingleSelectAsyncDropdown
components to make them more readable when they appear above the input control.MetricCard
subscription to fetch a single metric at a time instead of fetching all metrics.MInputSwitch
component now shows the "pointer" cursor when you hover on the label, correctly indicating that you can click on the label to toggle the switch.OverviewLists
for date-only items.MTimeSeriesBarChart
default y-axis values' abbreviation.MInputNumber
where fields with the clearOnZero
option enabled did not emit the expected value when set to 0
.MEventTimeline
.Please apply the following changes to your project to ensure compatibility with the latest Metaplay SDK.
Backward-Incompatible Changes
Bump your game's MetaplayCoreOptions.supportedLogicVersions
to force a synchronized update of your game client and server.
The Metaplay SDK now requires Helm chart v0.8.0 or later for cloud deployments.
Migration Steps:
Update your metaplay-project.yaml
to the latest Helm chart version.
serverChartVersion: 0.x.y
serverChartVersion: 0.8.0
Roll out the change to each environment by running the CI job to build and deploy a new version.
You can do this for each environment separately, whenever is a good time for you.
Unity 2021.3 Support Removed
Unity has officially dropped support of Unity 2021.3 with the release of Unity 6. The Metaplay SDK no longer supports Unity 2021.3, update your project to Unity 2022.3 or later before starting the Metaplay SDK update.
The Metaplay SDK now requires Unity 2022.3 or newer for all projects. We recommend updating Unity before starting the MetaplaySDK update.
Migration Steps:
Premium SDK Update Support
If your support contract includes Metaplay-provided SDK updates, all the following steps have already been applied to your project. You can skip this migration guide!
This guide offers step-by-step instructions for migrating your project to the latest version of the Metaplay SDK. You can skip the migrations steps for features you are not using in your project.
The following core SDK changes affect all Metaplay projects:
Metaplay's built-in database schema has changed, and you need to apply the migration steps to your project.
Migration Steps:
Generate the database schema migration code with:
# Install or update the EFCore tool:
Backend/Server$ dotnet tool install -g dotnet-ef
# Then, generate the migration code:
Backend/Server$ dotnet ef migrations add MetaplayRelease33
Then, add the generated files to your project's source control. For example, using Git:
Backend/Server$ git add .
Backend/Server$ git commit -m "Database schema migrations"
The migration steps will be automatically applied when you deploy the updated game server into an environment.
All server and tool builds depending on MetaplaySDK R33 require using the .NET toolchain from the .NET 9 SDK.
Migration Steps:
Update the sdk
entry in the global.json
file of your project to specify at least version 9.0.100
:
{
"sdk": {
"version": "9.0.100",
"rollForward": "latestFeature"
}
}
Once the R33 update is merged to your development branch, instruct all developers to install and switch to the .NET 9 SDK.
Update the .NET toolchain version used by any CI jobs.
Note that Dockerfile.server
shipped with the Metaplay SDK has been updated to use the .NET 9 SDK so any docker image builds in CI will use the correct .NET SDK version without further action required.
Update the target framework of your server and tools builds to target .NET 9 runtime. The actions for updating your project are:
Update the dotnetRuntimeVersion
entry in the metaplay-project.yaml
file to specify 9.0
:
dotnetRuntimeVersion: "8.0"
dotnetRuntimeVersion: "9.0"
Edit the TargetFramework
property in all .csproj
files to specify net9.0
:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
...
Verify that your code continues to build against .NET 9:
MyProject$ metaplay build server
The following LiveOps Dashboard changes affect projects that have a game-specific dashboard project:
The LiveOps Dashboard requires Node version 20 or later, with the latest recomended version being 22.14.0.
Migration Steps:
Check your current Node version by running: node --version
.
If your Node version is lower than 20.x, or you want to update to the latest recomended version:
# Install Node 22.14.0 with Node Version Manager (nvm).
nvm install 22.14.0
# Use the newly installed version.
nvm use 22.14.0
# (Optional) Set the new version as the default.
nvm alias default 22.14.0
Update the Node version in your project's package.json
file to reflect the new requirement:
"engines": {
"node": "20.x"
"node": ">=20"
}
Migration Steps:
git clean -fdx ':(glob)**/node_modules/*'
from the root of your repository. This clears any currently installed dependencies.git clean -fdx ':(glob)**/dist/*'
from the root of your repository. This clears any previously built files.pnpm-lock.yaml
file from the root of your repository. This clears the cached dependency versions.As usual, we have updated the underlying dependencies and configurations of the LiveOps Dashboard. This causes changes to several configuration files, which you will need to update in your dashboard project. We use the MetaplaySDK/Frontend/DefaultDashboard
folder as the source of truth for these files.
Migration Steps:
Update the package.json
to update your project's dependencies.
package.json
file directly from the MetaplaySDK/Frontend/DefaultDashboard
directory. Next, restore the name property inside the file to that of your project. This is typically of the form "name": "<projectName>-dashboard"
.Run pnpm install
in your 'Dashboard' folder. This recreates all of the above files and folders with the correct dependencies.
As a part of continued code review and polish of the dashboard codebase, we have done a few breaking changes to internal APIs that you may be using in your custom components.
MCallout
ComponentThe badge
slot was removed from MCallout
component and replaced with badgeText
and badgeVariant
properties instead.
Migration Steps:
Find all instances of the MCallout
component that use the badge
slot.
Migrate to use properties instead:
MCallout(title="My Callout Title")
template(#badge) My Badge Text
MCallout(
title="My Callout Title"
badge-text="My Badge Text"
badge-variant="success"
)
MInputSwitch
ComponentThe disabled
prop was deprecated in favor of disabledTooltip
prop.
Migration Steps:
Find all instances of the MInputSwitch
component that use the disabled
prop.
Migrate to use the disabledTooltip
prop instead:
MInputSwitch(
disabled
...
MInputSwitch(
disabled-tooltip="This is the reason why it is disabled"
...
)
getGameData()
API FunctionThe getGameData()
API function was deprecated in favor of the higher performance version getGameDataByLibrary(libraryNames: string[])
.
Migration Steps:
Replace uses of getGameData()
with getGameDataByLibrary(libraryNames: string[])
and pass the libraries you wish to load as a parameter:
const gameData = await initializationApi.getGameData()
const gameData = await initializationApi.getGameDataByLibrary(['Producers', 'ProducerKinds'])
initializationApi.addStringIdDecorator('Game.Logic.ProducerTypeId', (stringId: string): string => {
const producer = gameData.gameConfig.Producers[stringId]
const producerKind = gameData.gameConfig.ProducerKinds[producer.kind]
return `${producer.name} (${producerKind.name})`
})
getGameDataSubscriptionOptions()
API FunctiongetGameDataSubscriptionOptions()
was deprecated in favor of the higher performance version getGameDataByLibrarySubscriptionOptions(libraryNames: string[])
.
Migration Steps:
Replace uses of getGameDataSubscriptionOptions()
with getGameDataByLibrarySubscriptionOptions(libraryNames: string[])
and pass the libraries you wish to load as a parameter:
const { data: gameData } = useSubscription(getGameDataSubscriptionOptions())
const { data: gameData } = useSubscription(getGameDataByLibrarySubscriptionOptions(['Producers', 'ProducerKinds']))
getGameData()
/getGameDataSubscriptionOptions
ReactiveIn R32 and earlier versions, the game data was loaded before the dashboard was rendered. This behavior is very convenient however when your game grows, it becomes quite slow.
As the game data is no longer available by default, we need to ensure that any custom components can handle this. There are 2 solutions to this problem, Vue Reactivity or ensuring that the data is fetched before the dashboard is rendered.
We strongly recommend using Vue's reactivity systems, the other approaches described here are temporary solutions to keep things working meanwhile.
The preferred approach is to make use of Vue3 reactivity. This will make your dashboard load faster and snappier to use, as the data is loaded asynchronously while the user can interact with the dashboard. However this does mean that you have to handle an extra state when the data is not available yet. Unfortunately there's no one size fits all solution, we'll discuss a few of the basics to help you get started.
Reactivity on it's own is quite simple, when the underlying data changes, it automatically updates the UI and dependent computed properties. As you might imagine, this is very useful for network requests and our subscriptions system. You can simply define a subscription, use it in your UI and it'll automatically update once it is done with downloading.
Additionally, Vue provides computed()
and watch()
to help keep your code reactive. computed
caches a value and "subscribes" to the dependencies, if the dependencies change, it'll re-evaluate and cache the new value. Quite useful for filtering collections or computing more complicated data, however you shouldn't create side-effects, but rather create a chain of computed
properties. watch
gives you a callback when the value you're observing changes, most of the time this is used to initialize a default value once the data has been downloaded.
Let's take a look at a few simple examples (note that some details are left out for brevity):
<template lang="pug">
//- Each time allProducers is recalculated, the UI is re-rendered as well.
//- Even if the player is actively playing, it updates the levels in real time!
div(v-if="allProducers !== undefined")
div(v-for="producer in allProducers")
span {{ producer.info }}, Level: {{ producer.level }}
</template>
<script lang="ts" setup>
const { data: gameData } = useSubscription(() => getGameDataByLibrarySubscriptionOptions(['Producers']))
const { data: playerData } = useSubscription(() => getSinglePlayerSubscriptionOptions(props.playerId))
// Since allProducers is dependent on both gameData and playerData,
// the value of allProducers will be recalculated when either of these change.
//
// After allProducers is recalculated, it triggers the UI to re-render as well.
const allProducers = computed(() => {
if (gameData.value && playerData.value) {
const availableProducers = gameData.value.gameConfig.Producers
return Object.keys(availableProducers).map((id) => {
if (id in playerData.value.model.producers) {
return playerData.value.model.producers[id]
} else {
return {
info: id,
level: 0,
}
}
})
} else {
return undefined
}
})
watch(
// Trigger when player data changes
playerData,
(newValue) => {
if (newValue !== undefined && selectedProducer.value === null) {
// Find and default to the first producer that the player has unlocked
selectedProducer.value = Object.keys(newValue.producers)[0] ?? null
}
},
// Setting immediate to true ensures that it is triggered when the component is first rendered, which is useful if the data is already in the cache
{ immediate: true }
)
</script>
Libraries can be cached before the dashboard completes loading, ensuring they are available when custom components are ran. Simply add calls to getGameDataByLibrary()
with the same library names as you use elsewhere.
Await calls to initializationApi.getGameDataByLibrary()
in gameSpecific.ts
:
export function GameSpecificPlugin(app: App): void {
// Feel free to add any customization logic here for your game!
setGameSpecificInitialization(async (initializationApi) => {
// Note that libraries are cached by the union of all array items, therefore requesting [a, b] will not cause [a] to be cached and vice-versa.
await initializationApi.getGameDataByLibrary(['Producers'])
await initializationApi.getGameDataByLibrary(['ProducerKinds'])
}
The previous behavior can easily be restored, simply add a call to getGameData()
. Note that getGameData()
and getGameDataByLibrary()
use different caching mechanisms and are thus fetching one will not cache the other.
Await a call to initializationApi.getGameData()
to gameSpecific.ts
:
export function GameSpecificPlugin(app: App): void {
// Feel free to add any customization logic here for your game!
setGameSpecificInitialization(async (initializationApi) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
await initializationApi.getGameData()
...
}
}
MetaScheduleBase
By default, Luxon parses DateTime
strings without time zone information into browser local time. This can lead to incorrect or misleading times displayed on the LiveOps Dashboard. In the case of MetaScheduleBase
being sent from the server, the schedule object has an ISO time string without time zone information and a timeMode
property that tells whether the schedule times are in UTC or player local time. We recommend handling both of these cases separately when displaying times from MetaScheduleBase
or similar objects.
Parse UTC times and show player local times correctly
div() #[MDateTime(:instant="DateTime.fromISO(schedule.start)" show-as="dateTime")]
div(v-if="schedule.timeMode === 'Utc'") #[MDateTime(:instant="DateTime.fromISO(schedule.start, { zone: 'utc' })" show-as="dateTime")]
div(v-else) MTooltip(content="The local start time of this schedule is local to the player's game time offset.")
//- Shorthand format 'ccc, DD, T' -> "Sat, 1 Jan 2000, 12:34"
| {{ DateTime.fromISO(schedule.start).toFormat('ccc, DD, T') }} local
The following core SDK changes affect projects that have implemented custom incident report types:
To visualize your incident reports correctly on the LiveOps dashboard, you must now implement the GetIncidentDashboardInfo()
method and return a populated IncidentDashboardInfo
object. This allows for better and easier visualization of incident report members.
Migration steps:
For each of your custom incident report types, implement GetIncidentDashboardInfo()
in the class deriving from PlayerIncidentReport
:
public class MyCustomPlayerIncident : PlayerIncidentReport
{
...
public override IncidentDashboardInfo GetIncidentDashboardInfo()
{
// Use the PreFillDashboardInfo() method to collect the incident's base class members into the dashboardInfo
IncidentDashboardInfo dashboardInfo = IncidentDashboardInfo.PreFillDashboardInfo(this);
// Optionally populate the ErrorInfo member to visualize errors and stacktraces neatly
dashboardInfo.ErrorInfo = new IncidentDashboardInfo.ErrorDashboardInfo() { ... };
// Optionally populate other members of the IncidentDashboardInfo class to visualize them neatly on the LiveOps dashboard
dashboardInfo.NetworkInfo = new IncidentDashboardInfo.NetworkDashboardInfo() { ... };
dashboardInfo.PlayerModelDiffReport = new IncidentDashboardInfo.PlayerModelDiffReportInfo() { ... };
return dashboardInfo;
}
}
Optionally, implement a class deriving from IncidentDashboardInfo.ExtraIncidentDashboardInfo
and populate it in the method override. This allows you to easily visualize extra info for custom incidents without touching the dashboard code:
[MetaSerializableDerived(101)]
public class MyCustomPlayerIncident : PlayerIncidentReport
{
...
[MetaSerializableDerived(101)] // You can give this the same tag ID as your incident report class
public class ExtraDashboardInfo : IncidentDashboardInfo.ExtraIncidentDashboardInfo
{
// Note that we're using implicit MetaMembers here, so no attribute needed for members!
public string MyExtraString;
}
public override IncidentDashboardInfo GetIncidentDashboardInfo()
{
...
// Optionally populate the ExtraInfo member with any additional info that you also want to visualize on the dashboard
dashboardInfo.ExtraInfo = new ExtraDashboardInfo() { MyExtraString = ... };
return dashboardInfo;
}
}
These changes affect you in case you happen to use any of the APIs changed. You can build your project to get a list of any incompatibilities instead of going through the list one item at a time.
The Metaplay SDK now requires the source generators provided by the analyzer project Metaplay.CodeAnalyzers.Shared
to be run in the compilation of any assemblies that contain Metaplay integration code. For Unity builds (editor and client) this is taken care of automatically by the MetaplaySDK Unity Package declaring the analyzer, for server builds it is now required to have a project reference to the Metaplay.CodeAnalyzers.Shared
project.
Migration Steps:
Add a project reference to all .csproj files in your server solution:
<ProjectReference Include="$(MetaplayServerPath)\CodeAnalyzers.Shared\Metaplay.CodeAnalyzers.Shared.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</ProjectReference>
The population of the Integration Registry containing information about types implementing the IMetaIntegration
interface has been moved from runtime init to happen as part of the build using source generation. This change adds the following requirements to integration types:
IMetaIntegrationConstructible
and IMetaIntegrationSingleton
types must have public constructors. Previously a private constructor was allowed as the reflection-based instance creation circumvented the visibility check.IMetaIntegration
types must have public or internal accessibility. Previously an integration type could be declared as a private nested type, this is no longer allowed. In this release, there is a temporary workaround that delegates access to the integration type to happen via runtime reflection. This workaround will be removed in a future release, so we strongly recommend updating your integration.Migration Steps:
Incorrect accessibility of integration types will cause a compilation error. Observe compilation errors of form
MetaplayIntegrations_SharedCode_IntegrationRegistry.g.cs(20,173): Error CS0122 : 'GlobalOptions.GlobalOptions()' is inaccessible due to its protection level
And adjust the visibility of the constructor to public
accordingly.
IMetaIntegration types that have too limited visibility will currently result in a compiler warning of form
PlayerActor.cs(265,22): Warning MP_IRG_001 : Type 'Game.Server.Player.PlayerActor+GuildComponent' is an integration type for Metaplay SDK type 'global::Metaplay.Server.EntityGuildComponent' but is not publicly or internally visible
Review these compiler warning and adjust visibility to public
or internal
accordingly.
MetaplayCoreEditor.EnsureInitialized()
MetaplayCore is initialized as part of Unity Editor load using a InitializeOnLoadMethod
function. As the order of execution of InitializeOnLoadMethod
functions is arbitrary, previously any code that runs as part of editor load and required MetaplayCore to be initialized would call MetaplayCoreEditor.EnsureInitialized()
to force initialization. This is now replaced with MetaplayCoreEditor.RunAfterMetaplayEditorInit()
that you can use to register actions to be run once the MetaplayCore initialization is complete.
For example, if you previously had an InitializeOnLoadMethod
function in your project like this:
[InitializeOnLoadMethod]
static void EditorInitFunctionThatNeedsMetaplay()
{
MetaplayCoreEditor.EnsureInitialized();
InitOperation();
}
You would need to refactor the function to look like this:
[InitializeOnLoadMethod]
static void EditorInitFunctionThatNeedsMetaplay()
{
MetaplayCoreEditor.RunAfterMetaplayEditorInit(InitOperation);
}
Note that this change doesn't remove the need for introducing editor init time logic in a InitializeOnLoadMethod
.
IPlayerModel.IsBanned
and IPlayerModel.IsPermaBanned
were deprecated in favor of IPlayerModel.BanInfo
. Update your uses accordingly.
Migration Steps:
Replace uses of Is(Perma)Banned in sessions and player actions
if (Model.IsBanned)
if (Model.BanInfo != null)
{
// Handle if the player is banned
}
Replace uses of Is(Perma)Banned outside of sessions
BanInfo
is updated at the beginning of a session. If you're checking whether the player is banned outside of session/player action context, you can use the IsBannedAtTime
to verify whether the player is banned currently.
if (Model.IsBanned)
if (Model.BanInfo?.IsBannedAtTime(MetaTime.Now))
{
// Handle if the player is banned
}
Dictionary
and HashSet
in [MetaSerializable]
types.Dictionary
and HashSet
have an unspecified iteration order. This causes their serialization byte-stream representation to be non-deterministic. This causes any checksums computed from byte-stream to become non-deterministic too. There is no reason to use these types as MetaplaySDK provides drop-in replacements with deterministic iteration order. To continue using non-deterministic collections, the field can be marked with [MetaAllowNondeterministicCollection]
.
Migration Steps:
Replace any use of Dictionary
with MetaDictionary
in [MetaSerializable]
types.
[MetaSerializable]
public class MyType
{
[MetaMember(1)] Dictionary<string, int> Dictionary;
[MetaMember(1)] MetaDictionary<string, int> Dictionary;
}
Replace any entry iteration using KeyValuePair
to use tuple syntax:
foreach (KeyValuePair<TKey, TValue> entry in dictionary)
foreach ((TKey, TValue) entry in dictionary)
// or
foreach ((TKey key, TValue value) in dictionary)
Replace any use of HashSet
with OrderedSet
in [MetaSerializable]
types.
[MetaSerializable]
public class MyType
{
[MetaMember(1)] HashSet<int> PrimeNumbers;
[MetaMember(1)] OrderedSet<int> PrimeNumbers;
}
If MetaDictionary
or OrderedSet
cannot be used, mark the field with [MetaAllowNondeterministicCollection]
.
[MetaSerializable]
public class MyType
{
[MetaAllowNondeterministicCollection]
[MetaMember(1)] HashSet<string> PrimeWords;
}
PersistedParticipantDivisionAssociation
argument to custom ILeagueIntegrationHandler.HandleLeagueOnSessionSubscriber
.If you have a custom ILeagueIntegrationHandler
class for a league that overrides the HandleLeagueOnSessionSubscriber
method, it now takes in a PersistedParticipantDivisionAssociation
parameter that contains the assignment database entry for the league and participant. This change was done to reduce excess database calls on session start, since now we can fetch the assignment entry for all leagues in a single operation, instead of doing it for each league separately.
Migration Steps:
Replace Task HandleLeagueOnSessionSubscriber()
with Task HandleLeagueOnSessionSubscriber(PersistedParticipantDivisionAssociation currentAssociation)
Remove any now-obsolete database calls to fetch the association.
public async Task HandleLeagueOnSessionSubscriber()
public async Task HandleLeagueOnSessionSubscriber(PersistedParticipantDivisionAssociation currentAssociation)
{
...
PersistedParticipantDivisionAssociation currentAssociation = await MetaDatabase.Get().TryGetParticipantDivisionAssociation(LeagueId, PlayerId);
...
}
MetaDictionary
GettersMetaDictionary
's methods TryGetValue(...)
and GetValueOrDefault(...)
now provide more useful nullability information for the returned value, similar to standard collections such as Dictionary
. This may trigger new warnings in your code if you have enabled nullability warnings.
You should inspect the warnings and fix them by either adding the necessary nullability annotations in your code or fixing your code's behavior in case the warning identified an actual bug.
Migration Steps:
Fix "Converting null literal or possible null value to non-nullable type" warnings by adding nullability annotations.
MetaDictionary<int, string> myDict = new();
string x = myDict.GetValueOrDefault(123);
string? x = myDict.GetValueOrDefault(123);
if (!myDict.TryGetValue(456, out string y))
if (!myDict.TryGetValue(456, out string? y))
return;
Fix "Dereference of a possibly null reference" warnings. The specific fix is case by case. For example:
MetaDictionary<int, MyClass> myDict = new();
string x = myDict.GetValueOrDefault(123).ToString();
string x = myDict.GetValueOrDefault(123)?.ToString() ?? "<null>";
if (!myDict.TryGetValue(Id, out MyClass? y))
{
_log.Error("Could not find object {Id}", y.GetId()); // Mistakenly using `y`
_log.Error("Could not find object {Id}", Id);
return;
}
Razor view engine is no longer enabled by default in AdminApi and in PublicWebApi. If you use Razor views, you must enable it manually in your PublicWebApi
actor. AdminApi
does not support Razor views and any views should be migrated to PublicWebApi
.
Migration Steps:
If you use Razor views on PublicWebApi
, inherit PublicWebApiActor
if you haven't already and add AddRazorViewEngine()
call in ConfigureServices()
public class MyPublicWebApiActor : PublicWebApiActor
{
protected override void ConfigureServices(IServiceCollection services)
{
base.ConfigureServices(services);
services.AddMvcCore().AddRazorViewEngine();
}
}
If you use Razor views on AdminAPI, migrate them to PublicWebApiActor
.
OfflineServer.OnSessionStart()
Signature.OnSessionStart()
has an new parameter bool isFirstLogin
. If your implementation overrides this method, you must update the method signature.
Migration Steps:
In your OfflineServer
implementation, update the signature:
public class OfflineServer : DefaultOfflineServer
{
protected override void OnSessionStart(IPlayerModelBase model)
protected override void OnSessionStart(IPlayerModelBase model, bool isFirstLogin)
{
...
}
}
GlobalState
schema versionThe schema version of GlobalState
has been updated to support scheduling experiments. If your project implements a customized GlobalState
, you must update the schema version.
Migration Steps:
In your GlobalState
implementation, update the schema version:
[SupportedSchemaVersions(7, 9)]
[SupportedSchemaVersions(7, 10)]
public class MyGameGlobalState : GlobalState
{
...
}
ConfigArchiveBuildUtility.ReadArchiveHeader()
callsReadArchiveHeader()
now returns a struct with named fields instead of a tuple. This improves readability and enables future extensions to the header type.
Migration Steps:
In your ReadArchiveHeader()
calls, update the return type:
(int schemaVersion, ContentHash archiveVersion, MetaTime timestamp, int numEntries) = ConfigArchiveBuildUtility.ReadArchiveHeader(reader);
ConfigArchiveBuildUtility.ArchiveHeader header = ConfigArchiveBuildUtility.ReadArchiveHeader(reader);
Use the struct fields instead of extracted tuple values:
schemaVersion
header.SchemaVersion
archiveVersion
header.Version
timestamp
header.CreatedAt
numEntries
header.NumEntries
AnalyticsSinkBigQuery:AddContextFieldsAsExtraEventParams
OptionAnalyticsSinkBigQuery:AddContextFieldsAsExtraEventParams
Runtime Option has been removed and is now always on, which was already the default mode. All references to this option must be removed.
The mode adds all fields from AnalyticsContext
into the BigQuery row as if they were fields of the Analytics Event itself, except that they have a $c:
prefix for their name.
Migration Steps:
In your server Config/Options.<env>.yaml
, remove references enabling the mode:
AnalyticsSinkBigQuery:
AddContextFieldsAsExtraEventParams: true
If in your Config/Options.<env>.yaml
you disable the mode, you must remove the flag, and update your BigQuery processing pipeline to support the Context Fields:
AnalyticsSinkBigQuery:
AddContextFieldsAsExtraEventParams: false
MaxPersistedSegmentsToRemoveAtOnce
from Event Log ConfigurationThe MaxPersistedSegmentsToRemoveAtOnce
property has been removed from the EventLogForPlayer
and EventLogForGuild
sections in server options. The logic for removing event log segments has been changed and this property is no longer appropriate.
If you have specified this value, remove it.
Migration Steps:
In your Options.*.yaml
files, remove MaxPersistedSegmentsToRemoveAtOnce
in EventLogForPlayer
and EventLogForGuild
.
EventLogForPlayer:
MaxPersistedSegmentsToRemoveAtOnce: 10
# ...
EventLogForGuild:
MaxPersistedSegmentsToRemoveAtOnce: 10
# ...
MultiplayerEntityActorBase.SendTo*()
Calls.In MultiplayerEntityActorBase
, SendToClientEntity()
has been renamed to SendToClient()
, and SendToAllClientEntities()
has been renamed to SendToAllClients()
.
Migration Steps:
Replace calls to SendToClientEntity()
with SendToClient()
SendToClientEntity(clientSession, new MyMessage());
SendToClient(clientSession, new MyMessage());
Replace calls to SendToAllClientEntities()
with SendToAllClients()
SendToAllClientEntities(new MyMessage());
SendToAllClients(new MyMessage());
PlayerActorBase.TryGetOwnerSession()
Calls.TryGetOwnerSession()
has been renamed to TryGetSession()
.
Migration Steps:
Replace calls to TryGetOwnerSession()
with TryGetSession()
EntitySubscriber session = TryGetOwnerSession();
EntitySubscriber session = TryGetSession();
if (session != null)
{
SendMessage(session, new MyMessage());
}
UnityWebRequest.SendWebRequest()
Calls.Global extension method GetAwaiter()
to UnityWebRequest.SendWebRequest()
has been removed as it conflicted with AsyncOperation
extensions.
Migration Steps:
Use extension in UnityEngine
namespace (Unity 2023.1 and later):
using UnityEngine;
using (UnityWebRequest uploadRequest = UnityWebRequest.Get(url))
{
await uploadRequest.SendWebRequest();
}
Alternatively, implement awaiting manually:
using (UnityWebRequest uploadRequest = UnityWebRequest.Get(url))
{
await uploadRequest.SendWebRequest();
UnityWebRequestAsyncOperation asyncOp = uploadRequest.SendWebRequest();
TaskCompletionSource<int> tcs = new TaskCompletionSource<int>();
asyncOp.completed += op => tcs.TrySetResult(0);
await tcs.Task;
}
MultiplayerEntityActorBase.OnPostTick()
with OnAfterTick()
.OnPostTick(long tick)
has been replaced by OnAfterTick()
.
Migration Steps:
Replace OnPostTick(long tick)
method with OnAfterTick()
protected override void OnPostTick(long tick)
protected override void OnAfterTick()
{
// tick = Model.CurrentTick - 1
...
}
allowSynchronousExecution
Argument from PlayerActorBase.EnqueueServerAction()
Calls.The parameter allowSynchronousExecution
has been removed due to unpredictable behavior. The parameter allowed action execution to be performed synchronously in conditions that depended on the sequence of previous calls to the method after the latest flush.
Migration Steps:
Remove allowSynchronousExecution
argument if synchronous execution is not absolutely necessary
EnqueueServerAction(action, allowSynchronousExecution: true);
EnqueueServerAction(action);
Use ExecuteServerActionImmediately()
if synchronous execution is required.
EnqueueServerAction(action, allowSynchronousExecution: true);
ExecuteServerActionImmediately(action);
If your project has a league where divisions use a different schedule from the leaguemanager, you'll need to override the AutoSyncSeasonSchedule
property in your DivisionActor
and set it to false.
If you're not sure if this applies to your project, a common way to do this is to set the division model's StartsAt
, EndsAt
and EndingSoonStartsAt
manually in SetUpModelAsync()
. If you don't see any code modifying those values in your division actor implementation, your project is most likely not affected.
public sealed class MyGameDivisionActor : PlayerDivisionActorBase<MyGameDivisionModel, PersistedDivision, MyGameLeagueManagerOptions>
{
// Disable season schedule synchronization for divisions.
// Otherwise, the custom division schedule would be overwritten.
protected override bool AutoSyncSeasonSchedule => false;
//...
}
IMetaplayLifecycleDelegate.OnSessionStarted()
to OnSessionStartedAsync()
.OnSessionStarted()
is now OnSessionStartedAsync()
that returns a Task
to allow for asynchronous session start logic.
Migration Steps:
For synchronous logic, convert the method to return Task.CompletedTask
:
void IMetaplayLifecycleDelegate.OnSessionStarted()
Task IMetaplayLifecycleDelegate.OnSessionStartedAsync()
{
...
return Task.CompletedTask;
}
For asynchronous logic, convert the method to return a Task
that completes when the asynchronous start operation is complete.
void IMetaplayLifecycleDelegate.OnSessionStarted()
async Task IMetaplayLifecycleDelegate.OnSessionStartedAsync()
{
SceneManager.LoadScene("Game Scene");
await SceneManager.LoadSceneAsync("Game Scene");
}
Application.LoadLocalAssemblies()
to AssemblyUtil.LoadLocalAssemblies()
.LoadLocalAssemblies()
has been moved to core to make it accessible to external applications. The method no longer returns the loaded assemblies.
Migration Steps:
To load assemblies eagerly, use AssemblyUtil
:
Application.LoadLocalAssemblies();
AssemblyUtil.LoadLocalAssemblies();
To enumerate all assemblies, use AppDomain.CurrentDomain.GetAssemblies()
:
Assembly[] assemblies = Application.LoadLocalAssemblies();
AssemblyUtil.LoadLocalAssemblies();
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
MetaplayCore.InitializeForExternalApp()
Instead of MetaplayCore.Initialize()
in External Apps.MetaplayCore.InitializeForExternalApp()
is a new helper for conveniently initializing Metaplay with the correct assembly scope and with a on-demand generated serializer.
Migration Steps:
To initialize Metaplay, use the convenience method:
MetaplayCore.Initialize(init => init.ConfigureMetaSerialization("MyHelperApp"));
MetaplayCore.InitializeForExternalApp("MyHelperApp");