Multiplayer and dedicated servers
A modded session only works if every participant runs the same mods at the same file versions. cfcore-sdk-ue handles that with one interface, ICFCoreClientServerLibrary, and two entry points: one the server calls before it opens, one each client calls while it joins. Both converge on the same install and validation machinery.
The interface carries no transport. It takes mod ids on the server side, file ids on the client side, and returns installed mods. Getting the server's file id list to a joining client is your game's job. There is no matchmaking, server advertising, or session discovery in the SDK.
Before you call anything: the isServer prerequisite
AssureServerModsUpdated does nothing useful unless isServer is true in the FCFCoreSettings you pass to Initialize. Also set isServerPcOnly if the server accepts PC clients only, before calling Initialize, since neither field can be changed afterward.
| Setting | Type | Default | Effect |
|---|---|---|---|
isServer | bool | false | Three effects: the reported platform gains the server suffix (windows_server, not windows), every request gains the x-mod-server-request: true header, and premium downloads skip the protected URL path |
isServerPcOnly | bool | false | Read only when isServer is true. Becomes FCFCoreGetModsFilter::filterPcOnly on the server's mod query. Set this if your server only supports PC clients, for example a non-console dedicated server |
maxConcurrentInstallations | int32 | 3 | Caps parallel installs in either flow. Clamped with FMath::Max<int32>(1, ...), so below 1 behaves as 1 |
dynamicContentCategoryIds | TSet<int64> | empty | Category ids treated as dynamic content. Empty disables the library-wide ownership pass on every client join |
premiumMods.publicKeyPem | FString | empty | PEM key used to verify the premium check response signature. Empty skips verification |
filterPcOnly true returns the latest server mod that has a corresponding Windows mod; false returns one that has all supported platforms. A server accepting console clients should leave it false, or it can pin revisions whose only guaranteed counterpart is Windows.
Neither flag can change after initialization. FCFCoreUpdatableSettings, the only struct UpdateSettings (Update Settings) accepts, holds just updateDefaultLanguage and defaultLanguage. A process is a server or a client for its whole lifetime.
The same three fields exist on UCFCoreEditorSettings for Project Settings configuration.
The SDK must be initialized, or both Blueprint nodes fire OnError with ECFCoreErrorCodes::FailedToInitialize and description Not initialized. Premium validation on the client path also needs an authenticated player who is online.
The client-server library
In C++: CFCoreContext::GetInstance()->Library()->ClientServerLibrary(). In Blueprint the two flow methods sit on UCFCoreSubsystem under cfcore|Library|ClientServer.
| Method | Role | Input | Blueprint node |
|---|---|---|---|
AssureServerModsUpdated | Server | FAssureServerModsUpdatedParams | Assure Server Mods Updated |
AssureClientModsUpdated | Client | TArray<int64> of server file ids | Assure Client Mods Updated |
GetInstalledMods | Either | TSharedRef<TArray<int64>> of mod ids | None (C++ only) |
ICFCoreClientServerLibrary::GetInstalledMods has no Blueprint equivalent, no error channel, and runs no validation, so its statuses can be stale. From Blueprint use the general Get Installed Mods node and filter yourself, with Perform Mods Validation first if status matters.
| C++ delegate | Parameters | Notes |
|---|---|---|
FModsUpdateProgressDelegate | const FModsUpdateProgress&, const TOptional<FLibraryProgress>&, const TOptional<FCFCoreMod>& | Both optionals unset on a phase change, set together on a per-mod install tick |
FModsUpdateDelegate | TOptional<TArray<FInstalledMod>>, TOptional<FCFCoreError> | Exactly one of the two is meaningful per call |
FGetInstalledModsDelegate | TArray<FInstalledMod> | No error parameter |
The Blueprint bridge flattens the optionals: OnProgress always receives UpdateProgress, ModInstallProgress, and Mod as plain values, substituting defaults unless both are set, and errors route to a separate OnError pin. Branch on Mod.id != 0 to tell a phase-change tick from a real per-mod tick.
There is no cancel-the-join call and no free-disk-space call in the current release. Do not plan a flow around either.
Server flow: assuring the server's mod set
How it works
- Pass the mod ids you want the server to run, plus
devModIdsif you need unpublished revisions for testing, which take precedence overmodIds. - The SDK fetches mod details for the requested ids, using
isServerPcOnlyto pick the right platform variant per mod. - An unavailable mod is uninstalled locally and the call fails with
DetectedUnavailableMod. Premium mods are exempt, since they should not become unavailable to players who bought them. - The shared update and validation stage runs.
// Headers: cfcore_context.h, library/cfcore_library.h,
// library/cfcore_client_server_library.h,
// library/models/assure_server_mods_updated_params.h
// Error handling abbreviated for length.
void AMyDedicatedServer::AssureModSet(const TArray<int64>& RequiredModIds) {
FAssureServerModsUpdatedParams Params;
Params.modIds = RequiredModIds;
CFCoreContext::GetInstance()->Library()->ClientServerLibrary()->AssureServerModsUpdated(
Params,
ICFCoreClientServerLibrary::FModsUpdateProgressDelegate::CreateLambda(
[](const FModsUpdateProgress& Phase,
const TOptional<FLibraryProgress>& ModInstallProgress,
const TOptional<FCFCoreMod>& Mod) {
if (!ModInstallProgress.IsSet() || !Mod.IsSet()) {
return; // Phase change only: Validating, then Installing.
}
UE_LOG(LogTemp, Log, TEXT("%s: %d%%"), *Mod->name,
ModInstallProgress->dataTransfer.progress);
}),
ICFCoreClientServerLibrary::FModsUpdateDelegate::CreateLambda(
[this](TOptional<TArray<FInstalledMod>> InstalledMods,
TOptional<FCFCoreError> OptError) {
if (OptError.IsSet() && OptError->isError) {
HandleModSetFailure(*OptError);
return;
}
// Publish the file ids clients must match against.
TArray<int64> ServerFileIds;
for (const FInstalledMod& Installed : InstalledMods.GetValue()) {
ServerFileIds.Add(Installed.installedFile.id);
}
OpenServerForConnections(ServerFileIds);
}));
}
Blueprint: Assure Server Mods Updated (cfcore|Library|ClientServer), pins Target, params, OnProgress, OnUpdated, OnError. Read installedFile.id on each installed_mods entry to build the file id list you replicate to clients.
FAssureServerModsUpdatedParams field | Type | Meaning |
|---|---|---|
modIds | TArray<int64> | The mod ids you want installed on the game server |
devModIds | TArray<int64> | Requests the latest mod file in Ready state instead of the published one, so mod authors can test mod revisions before making them public. Takes precedence over modIds. Server-only: the filter field it maps to works only for servers |
Two gotchas, both surfacing as MissingInstalledMods. A mod whose mainFileId is not in its latestFiles is logged and silently dropped, failing later at final validation as a count mismatch. A mod id the API never returns (deleted, wrong game, typo) behaves identically. Log requested ids next to returned ids, and check for a "doesn't contain it's main file" log line, before suspecting disk problems.
Client flow: joining a server
The client path shares steps 1, 2 and 7 with the server flow. It differs in the middle.
How it works
- The SDK calls the platform matching endpoint with the server's file ids, which returns an array of
FFileobjects that match each of the file ids for the current platform. This is how a console client joins a server that reported Windows file ids. Zero matches fails withNoPlatformFilesMatched; an API failure surfaces asApiError. - Mod ids are taken from the matched files (
FFile::modId) and details fetched through the same query stage. - Premium ownership is validated for the matched set, then dynamic content ownership across the whole local library. Either failure ends the join.
- Installs run with
origin = EModInstallOrigin::ServerModuleand a trackingoriginofjoin_srv.
// Progress lambda omitted: identical shape to the server sample above.
CFCoreContext::GetInstance()->Library()->ClientServerLibrary()->AssureClientModsUpdated(
ServerFileIds, ProgressDelegate,
ICFCoreClientServerLibrary::FModsUpdateDelegate::CreateLambda(
[this](TOptional<TArray<FInstalledMod>> InstalledMods,
TOptional<FCFCoreError> OptError) {
if (OptError.IsSet() && OptError->isError) {
if (OptError->code == ECFCoreErrorCodes::ModsNotOwnedByUser) {
ShowPurchaseRequiredScreen();
} else {
ShowJoinFailed(*OptError);
}
return;
}
LoadModsAndConnect(InstalledMods.GetValue());
}));
Blueprint: Assure Client Mods Updated (cfcore|Library|ClientServer), pins Target, ServerFileIds (array of integer64), OnProgress, OnUpdated, OnError. Never read installed_mods without checking the error first: when the error optional is set, the installed-mods optional is unset.
The join builds its own install params, so you cannot pass install options in, and throttleDownloadKbps is fixed at 0. For throttling, drive those installs yourself with ICFCoreLibrary::Install (Install Mod Extended) before or after the join. To run the platform match alone, ApiMatchPlatformFiles (Match Platform Files By Ids) exposes the same call.
Server flow versus client flow
| Aspect | AssureServerModsUpdated | AssureClientModsUpdated |
|---|---|---|
| Input | Params struct (mod ids, dev mod ids) | TArray<int64> of file ids |
| Platform resolution | None. Uses filterPcOnly at query time | Per-platform file matching |
| File chosen per mod | The mod's mainFileId from latestFiles | The matched FFile for this client's platform |
| Unavailable mods | Uninstalled locally, then DetectedUnavailableMod. Premium exempt | Not checked |
| Premium ownership | Not checked | Checked for every non-freemium mod in the set |
| Dynamic content ownership | Not checked | Checked across the whole local library when dynamicContentCategoryIds is populated |
| Install origin | Default (ModPage) | ServerModule |
| Tracking | None added | origin = join_srv |
| Download throttling | Not configurable (default 0) | Not configurable (default 0) |
| Unpublished revisions | Supported through devModIds | Not available |
| Settings required | isServer true, optionally isServerPcOnly | None specific |
| Zero-match failure | MissingInstalledMods at final validation | NoPlatformFilesMatched before installing |
The shared update and validation stage
Both flows end in the same sequence, which is why they are named "assure". This is converge-to-target, not download.
- Installed mods are fetched for the requested ids, fresh server details copied onto them, and a validation pass runs. An invalid mod's status is set to
Invalidand persisted to on-disk state. - The installed list is re-read so decisions use post-validation statuses.
- Each pair is tested. A mod installs if it is absent (
installedFile.id == 0), if the required file id differs from the installed one (this covers downgrades as well as upgrades), or if its status isInvalid,Pending, orModified. - Mods needing work install in parallel, capped by
maxConcurrentInstallations. - All installs are awaited together. The first error is reported and the flow stops before final validation. No in-flight install is aborted by another one's failure.
- Final validation re-reads the installed mods. If none came back, if the count does not match the requested pairs, or if any pair still needs installing, the flow fails with
MissingInstalledMods.
Step 3 compares file ids, not dates. Joining a server that pins an older revision downgrades a client running a newer one, and leaving does not restore it. Plan a re-sync with ICFCoreLibrary::SyncWithServer (Synchronize Installed Mods with Server) when the player returns to single player.
Neither flow sets enabled or loadOrder. If every participant must load the same mods in the same order, set that with ICFCoreLibrary::UpdateInstalledModsProperties (Update Installed Mods Properties), which takes FInstalledModProperties entries carrying id, modId, enabled, and loadOrder.
Nothing reverts a join: leaving a session leaves those mods installed. Drive teardown from the ids you kept, with Uninstall (Uninstall Mod), SyncWithServer, or CancelInstallation (Cancel Mod Installation), which is also the only way to interrupt a join in progress. To fail early rather than mid-download, read freeDiskSizeInBytes from GetModsDirInfo (Get Mods Directory Info) first.
Progress reporting during a join
Progress arrives on two levels through one delegate: a coarse phase in FModsUpdateProgress, whose only field is state, and per-mod detail in FLibraryProgress paired with its FCFCoreMod. The library-wide OnModInstallProgress and OnModInstalled events also fire for installs these flows trigger.
EModsUpdateProgressState | Value | Emitted by these flows |
|---|---|---|
Validating | 0 | Yes, once at the start of both flows, both optionals unset |
Installing | 1 | Yes, on every per-mod tick, both optionals set |
SuccessfullyCompleted | 2 | No |
FailedToComplete | 3 | No (it is the struct's default value) |
Completion is never signalled through the progress delegate. Drive terminal UI state from the update delegate, or from OnUpdated and OnError. There is no aggregate percentage either, so a "3 of 7 mods, 42% overall" screen has to be aggregated by your code from the per-mod ticks.
FLibraryProgress field | Type | Meaning |
|---|---|---|
modId | int64 | Mod this tick belongs to. 0 on a substituted Blueprint default |
fileId | int64 | File being installed |
state | ELibraryProgressState | Fine-grained stage |
dataTransfer | FLibraryProgressDataTransfer | Percentage and throughput |
FLibraryProgressDataTransfer field | Type | Meaning |
|---|---|---|
progress | int32 | Percent 0-100 |
transferredBytes | int64 | Only relevant for downloading or uploading |
transferRateBytesPerSecond | int64 | Only relevant for downloading or uploading |
filename | FString | Only relevant for file operations (zip, copy, move, and so on) |
ELibraryProgressState in declaration order: Pending (default), Downloading, Uploading, Validating, PendingUnzipping, Unzipping, PendingZipping, Zipping, Patching, Copying, CleaningUp, Cancelling, SuccessfullyCompleted, FailedToComplete. Which you see depends on the commands a given mod needs, with Patching when a binary delta patch replaces a full download.
Installs run in parallel, so ticks for different mods interleave. Key per-mod UI off FLibraryProgress::modId or FCFCoreMod::id, never off arrival order.
Premium entitlement validation when joining a server
If a server hosts premium mods, the join is the enforcement point, and AssureClientModsUpdated runs the check for you.
- Every mod in the matched set joins the check list unless
FPremiumDetails::isFreemiumis true. Non-premium mods are included deliberately: the server-side check is an additional layer of protection beyond the client's own premium flag. - The list goes to
ICFCorePremiumMods::CheckMods(Premium Mods Check), which calls the authorized premium check and verifies the response signature. - Owned ids are subtracted from requested ids. Anything left is logged and the join fails with
ModsNotOwnedByUser.
This is a pass or fail gate, not a list. The error is constructed from the error code alone. The set of unowned mod ids is written to the SDK log and then discarded, so it does not reach your error handler. AssureClientModsUpdated can tell you that the player is missing something, and nothing more.
That matters if your connecting screen has to show which mods the player needs and what they cost, for example switching a Join button to Purchase with a price. Build that surface from your own call rather than from the error:
- Resolve the server's file ids to mods, as you already do to display the required mod list.
- Call
ICFCorePremiumMods::CheckMods(Premium Mods Check) with those mod ids. Send every mod whoseisFreemiumis false, matching what the join does, so your list agrees with the gate. - Subtract the returned owned ids yourself. What remains is the purchase list, and each mod's
premiumDetailsgives youtierPrice,currencySymbol,discountDataandtrialDetailsfor the button. - Treat a later
ModsNotOwnedByUserfrom the join as confirmation, not as the source of the list.
Doing your own check also keeps the price and the gate consistent, because both then read the same server response.
| Mod shape | Sent for the check | Blocks the join |
|---|---|---|
Non-premium (isPremium false, isFreemium false) | Yes | Only if the response does not list the id as owned. Decided server side; no local premium flag is read here |
Premium (isPremium true, isFreemium false) | Yes | Yes, unless the response lists the id as owned |
Freemium (isFreemium true) | No | No. A freemium mod can be installed without purchasing, then queries the game to unlock features per purchase, so it gates itself |
An authenticated player with a working connection is required, because the premium check requires the player to be online. Signature verification is optional: it passes immediately when no public key is configured, so the join proceeds unsigned. Turn it on with premiumMods.publicKeyPem, or at runtime with ICFCorePremiumMods::OverridePublicKey (Override Public Key), which the SDK recommends so players cannot override it.
Trials. Three conditions exempt a mod from an ownership check: it is not premium, it is freemium, or the player holds an active trial for it, an FOwnedPremiumMods::trialMods entry for that modId with isExpired false. The dynamic content pass below applies all three exemptions, so an active trial satisfies that pass. The matched-set pass here does not consult trialMods at all, and does not need to: the premium check reports a mod under an active trial as owned, so a trialling player can join a server that requires it for as long as the trial stays active.
The server side differs in two ways. A premium mod marked unavailable is treated as available anyway, and when isServer is true the SDK skips the protected premium download URL, since server mods are never protected and are treated as non-premium mods for that purpose. AssureServerModsUpdated runs no ownership check at all.
Recommended pattern. Let the join check the server's set, and keep a session-start check for what the player may use: ICFCorePremiumMods::GetPremiumModsV2 (Get My Premium Mods V2), honouring ownedMods plus non-expired trialMods before loading premium content. On ModsNotOwnedByUser, route to purchase with ApiGeneratePremiumCheckoutUrl (Generate Premium Checkout URL) plus PollModPurchase (Poll Mod Purchase) on PC, or ApiInitiatePurchase and ApiFinalizePurchase on console. Do not hand-roll a poll loop: the SDK ships PollModPurchase and StopPurchasePolling (Stop Purchase Polling).
Dynamic content in a multiplayer session
Dynamic content is how the SDK handles seeing other players' paid cosmetics: content downloaded automatically by the game so every player can share the visuals, so a skin resides on all players' machines but is not considered installed, viewable but not usable. FInstalledMod::dynamicContent true means the player has the mod installed but does not own it and did not ask for it. Create such installs through the normal install path with FInstallModAdditionalParams::dynamicContent set to true, exposed on Install Mod Extended; the SDK then stamps a dc tracking value of true and sets origin to DynamicDownload. A later real install flips the flag back to false.
EModInstallOrigin | Value | Where it comes from |
|---|---|---|
ModPage | 0 | The default, and therefore what the server flow records |
ServerModule | 1 | Set by AssureClientModsUpdated |
SubscribeModule | 2 | Subscription-driven installs |
DynamicDownload | 3 | Set when dynamicContent is true |
ResumeDownload | 4 | A resumed download |
ModTile | 5 | Installed from a mod tile, distinct from opening the full mod page. Passed by the caller, nothing in cfcore sets it |
AssureClientModsUpdated does not install the server's set as dynamic content. It leaves dynamicContent false, so the server's required mods become real installed mods. Peer cosmetics the joining player does not own are a separate concern you drive with Install Mod Extended.
Fill in dynamicContentCategoryIds, otherwise the SDK cannot enforce a premium ownership check on dynamic content when a player manipulates the local library metadata files on their device. Find your game's category ids in the Developer Portal at console.curseforge.com, under the relevant game class.
Gotcha, this check is library-wide, not session-wide. When that set is populated, every client join runs a second ownership pass that ignores the server's mod list. It scans all locally installed mods, keeps those whose dynamicContent is false and whose categories intersect dynamicContentCategoryIds, fetches owned premium mods with GetPremiumModsV2, drops any mod that is not premium, is freemium, or has an active trial, and for anything still unowned resets dynamicContent to true on disk and fails the join with ModsNotOwnedByUser. Three consequences:
- Left empty, the default, the pass returns immediately. It costs nothing and protects nothing.
- A join can fail over tampered local cosmetics unrelated to the server being joined. Your error copy should not claim the server requires a purchase.
- If fewer mod details come back than there are ids to check, the join fails with
MissingModsDetailsinstead. That is a data or connectivity problem, so retry rather than route to a store.
The category design is deliberate: the game treats those categories specially, so a mod whose category a player edited will simply not load.
Errors on a server start or a client join
| Error code | Raised by | Meaning and recovery |
|---|---|---|
FailedToInitialize | Both Blueprint nodes | SDK not initialized. Description is Not initialized |
NoPlatformFilesMatched | Client | No file matched this client's platform. Treat as an incompatible server, not a retry |
DetectedUnavailableMod | Server | One or more mods are set to unavailable on the CurseForge website. Those mods are already uninstalled locally. Fix the list, or the mods' availability in the Developer Portal at console.curseforge.com |
ModsNotOwnedByUser | Client | The player does not own one or more mods and needs to purchase them. Run your own CheckMods diff to build the purchase list |
FailedToVerifySignature | Client, premium check | The premium check response signature could not be verified. A trust failure, not an entitlement failure. Do not route the player to a store |
MissingModsDetails | Client, dynamic content pass | Mod details could not be retrieved from the server. Connectivity or unknown local content. Retryable |
MissingInstalledMods | Both, at final validation | An install produced nothing, a mod was dropped for a missing main file, or a requested mod id did not resolve. Check the log |
ApiError | Both | An API failure during the details query, the platform match, or the premium check. FCFCoreError::apiError carries the detail |
ModsNotOwnedByUser comes from either the joined mod set or the library-wide dynamic content pass, and the error does not say which, nor which mods. The CheckMods diff is also how you tell those two sources apart.
Check FCFCoreError::isError as well as the optional being set.
An empty input is not an error. Both flows fire the update delegate with an empty array and no error, so OnUpdated fires and OnError does not: a vanilla server and a vanilla join both look like success. The two Blueprint pins are mutually exclusive, exactly one runs per call.
Blocked servers and blocked server mods
CurseForge models player-side moderation of modded servers, which belongs in your join UI. ApiGetBlockedModsDetails (Get Blocked Mods Details) covers a player reporting a server, or reporting mods on a server, so that they either block the server or all servers that contain blocked mods. Both lists are scoped to the current game only.
FBlockedDetails field | Type | Meaning |
|---|---|---|
serverIds | TArray<FString> | Server ids the player has blocked |
modIds | TArray<int64> | Mod ids blocked in the context of modded servers |
blockedUIModIds | TArray<int64> | Mods blocked by the user from within the in-game browser or website |
blockedUIAuthorIds | TArray<int64> | Authors blocked by the user from within the in-game browser or website |
Reverse a block with ApiUnblockMods (Unblock Mods), which takes FUnblockModsRequest.
FUnblockModsRequest field | Type | Meaning |
|---|---|---|
blockedAuthors | TArray<int64> | Authors blocked from within the in-game browser or website |
blockedMods | TArray<int64> | Mods blocked from within the in-game browser or website |
blockedServerMods | TArray<int64> | Mods blocked by the user from within the context of a server, for example a join server screen |
blockedServers | TArray<FString> | Servers blocked by the user |
Recommended pattern: call Get Blocked Mods Details before you present a server list, then filter or flag servers whose id is in serverIds or whose advertised mod set intersects modIds, so a player is never asked to install content they blocked. In C++ both calls are on ICFCoreApi::Authorized(). Rating, reporting, and the wider blocking model are on Player actions; the layers behind them are on Moderation.
Session reporting and ready-made UI
After a session ends, attribute it as a server session with AnalyticsSendGamePlaySession (Send Game Play Session Analytic). FGamePlaySessionParams carries sessionLengthInSecs (int32), sessionType (ECFCoreSessionType, Local = 0 or Server = 1), modIds, and serverName (the server identifier, when sessionType is Server). Install attribution is already handled: the join stamps origin = join_srv, separating installs the player made in the browser UI from installs made while joining a server. See Analytics and Dashboards.
The optional cfcore-sdk-ue-ui module ships Blueprint widgets for the join case under cfcore_ui/Content/Widgets/ServerMods/: BP_CFCore_ServerModsWidget, BP_CFCore_ServerModsLoading, and BP_CFCore_ServerModsSubMenu. These are shipped assets, not documented API. On gamepad platforms the plugin's UMG surfaces are driven by a virtual cursor, which your code turns on and off per player controller with UCFCoreVirtualCursorFunctionLibrary::EnableVirtualCursor (Enable Virtual Cursor) and DisableVirtualCursor (Disable Virtual Cursor), each taking the controller on the PC pin. The general in-game UI is on Mod browser.