Skip to main content

Gameplay data

The mission scene plays the run; the server decides rewards and stars when the run ends. The scene gets the run's starting data, reports what happens (skills used, reward enemies killed, the outcome, the final health), and shows only the rewards the server returns.

You useIt gives you
IProjectVGameplayDataConsumerYou implement it once per mission scene; the SDK calls it with the run
IProjectVGameplayDataPresenterThe run: record skill uses, end the run, restart
IProjectVGameplayDataPolicyHelpers on the same object: check skill assets, track reward enemies, build the result for you
ProjectVGameplayStartingDataViewWhat to spawn: character, stats, skills, allies, reward enemies
ProjectVGameplayDataResultThe outcome of a call: rewards and stars, or the failure

There is no screen contract here. The run starts on the Campaign screen with BeginMissionAsync; the SDK then finds your consumer in the loaded scene.

Quick startโ€‹

using System.Collections.Generic;
using System.Threading;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Character;
using SilverTiger.ProjectV.Unity.Presentation.Gameplay;
using UnityEngine;

public sealed class MissionDataBinding : MonoBehaviour, IProjectVGameplayDataConsumer
{
private IProjectVGameplayDataPolicy? run;
private ProjectVGameplayStartingDataView? data;
private readonly Dictionary<Component, ProjectVGameplayDropRegistration> drops = new();

public void BindGameplayData(IProjectVGameplayDataPresenter presenter, ProjectVGameplayStartingDataView startingData)
{
run = presenter as IProjectVGameplayDataPolicy;
data = startingData;
if (run == null || !SkillAssetsMatch(run))
{
_ = presenter.CancelRunAsync(CancellationToken.None); // costs no stamina
ShowError("This mission cannot start.");
return;
}
SpawnCharacter(startingData.ActiveCharacterId, startingData.Stats);
SpawnAllies(startingData.Allies);
PlaceRewardEnemies(startingData.Drops); // put each SlotId on its enemy
}

public void UnbindGameplayData(IProjectVGameplayDataPresenter presenter)
{
if (ReferenceEquals(run, presenter))
{
run = null;
data = null;
drops.Clear();
}
}

// A reward enemy became active; spawnCount changes on every reuse from the pool.
public void OnRewardEnemyActivated(Component enemy, string slotId, long spawnCount)
{
if (run != null && run.RegisterDropOwner(enemy, slotId, spawnCount, out var registration).IsSuccess)
{
drops[enemy] = registration;
}
}

public void OnRewardEnemyDied(Component enemy)
{
if (run != null && drops.TryGetValue(enemy, out var registration))
{
run.RecordDropDefeated(registration);
}
}

public void OnRewardEnemyDeactivated(Component enemy)
{
if (run != null && drops.Remove(enemy, out var registration))
{
run.UnregisterDropOwner(registration); // a recorded death still counts
}
}

public void OnSkillUsed(ProjectVGameplayDataSkillSlot slot)
{
if (run == null || data == null)
{
return;
}
foreach (var cast in data.CastSkills)
{
if (cast.Slot == slot)
{
run.RecordSkillActivated(cast.SkillId, slot);
}
}
}

// Win, lose or give up. A Retry button on the result screen calls this again.
public async void OnRunEnded(ProjectVGameplayDataOutcome outcome, long kills, float health, int armor, long experience)
{
if (run == null || !run.OwnsAuthoritativeRun)
{
return;
}
run.ObserveOutcome(outcome, kills);
var result = await run.CompleteObservedAsync(health, armor, experience, CancellationToken.None);
if (this == null)
{
return;
}
if (!result.IsSuccess)
{
ShowRetry(result.SafeMessage); // keep the result screen open
return;
}
ShowRewards(result.Rewards, result.MailedRewards, result.Stars);
}

// Pause > Restart: ends the run without a result and without spending stamina.
public async void OnRestartClicked()
{
if (run == null)
{
return;
}
var result = await run.CancelRunAsync(CancellationToken.None);
if (this != null && result.IsSuccess)
{
ReturnToCampaign();
}
}

private bool SkillAssetsMatch(IProjectVGameplayDataPolicy policy)
{
if (!policy.ValidateSkillCatalog(ReadSkillAssetIds()).IsSuccess)
{
return false;
}
foreach (var slot in new[] { ProjectVGameplayDataSkillSlot.Character, ProjectVGameplayDataSkillSlot.Weapon })
{
var (level, skillId) = PickSkillAsset(slot);
if (!policy.ValidateCastMapping(slot, level, skillId).IsSuccess)
{
return false;
}
}
return true;
}

private IReadOnlyList<ProjectVGameplaySkillId> ReadSkillAssetIds() => new List<ProjectVGameplaySkillId>();
private (int, ProjectVGameplaySkillId) PickSkillAsset(ProjectVGameplayDataSkillSlot slot) => default;
private void SpawnCharacter(string characterId, ProjectVCharacterStats stats) { }
private void SpawnAllies(IReadOnlyList<ProjectVGameplayAllyView> allies) { }
private void PlaceRewardEnemies(IReadOnlyList<ProjectVGameplayDropView> rewardEnemies) { }
private void ShowError(string message) { }
private void ShowRetry(string message) { }
private void ShowRewards(IReadOnlyList<ProjectVGameplayRewardView> rewards,
IReadOnlyList<ProjectVGameplayRewardView> mailed, ProjectVGameplayStarResultView? stars) { }
private void ReturnToCampaign() { }
}

That is the whole binding: spawn from the starting data, report what happens, end the run, show what the server returns. You do not filter reward enemies, work out stage rules or build statistics; the SDK does that from what you report.

Lifecycleโ€‹

Campaign: BeginMissionAsync(missionId) succeeds -> your game loads the gameplay scene
SDK finds the one IProjectVGameplayDataConsumer in that scene
SDK -> consumer.BindGameplayData(presenter, startingData) (Unity main thread)
during play: RegisterDropOwner / RecordDropDefeated / RecordSkillActivated
end of run: ObserveOutcome, then CompleteObservedAsync (win, lose or give up)
or: CancelRunAsync (Pause > Restart)
SDK -> consumer.UnbindGameplayData(presenter) (scene unloads or run ends)

What you receiveโ€‹

BindGameplayData receives a ProjectVGameplayStartingDataView. Everything in it is fixed when the run starts; changes on the Character screen afterwards do not affect the run.

MemberUse it for
ActiveCharacterIdThe character to spawn. Spawn this one, not a locally selected one
ActiveCharacterLevelThat character's level, 1 or higher
StatsEvery stat for this run: health, attack, armor, energy, regen, move and attack speed, range. Apply all of them instead of local asset values
MaximumHealth, MaximumArmorThe starting maximums (the same health and armor as Stats)
SkillCatalogIdsThe character's skill-tree IDs, to match your skill assets
SkillsThe skill-tree ranks for this run (SkillId, Rank)
CastSkillsThe Weapon and Character skills: Slot, Level (1 or higher) and the exact SkillId
AlliesTeammates that join the run: CharacterId, Level, Experience, Stats, CastSkills. Empty when there are none
DropsReward enemies: SlotId, Stage, Rewards. Put each SlotId on its enemy in the scene; show Rewards as its loot

Bind the runโ€‹

void BindGameplayData(IProjectVGameplayDataPresenter presenter, ProjectVGameplayStartingDataView startingData)
ParameterTypeValue
presenterIProjectVGameplayDataPresenterThe run. Keep it; cast it to IProjectVGameplayDataPolicy for the helpers
startingDataProjectVGameplayStartingDataViewWhat to spawn (above)

The SDK calls it once per run, on the Unity main thread, when the scene has loaded. Set up the character, skills and reward enemies here.

SituationWhat happens
BindGameplayData throwsThe run is cancelled and UnbindGameplayData is called
No component in the scene implements the consumerNothing is bound and an error is logged: "An admitted gameplay scene must contain exactly one data binding consumer."
Several components implement itNothing is bound and an error is logged: "An admitted gameplay scene contains multiple data binding consumers."

Components on inactive objects count too. Keep exactly one.

Unbind the runโ€‹

void UnbindGameplayData(IProjectVGameplayDataPresenter presenter)

Called when the scene unloads or the run ends. Stop using the presenter: every call on it fails from now on with "The gameplay data binding no longer belongs to the active run."

Check the skill assetsโ€‹

ProjectVGameplayDataResult ValidateSkillCatalog(IReadOnlyList<ProjectVGameplaySkillId> skillIds)
ProjectVGameplayDataResult ValidateCastMapping(ProjectVGameplayDataSkillSlot slot, int level, ProjectVGameplaySkillId skillId)
ParameterValue
skillIdsThe skill IDs read from your skill assets; each once, each in SkillCatalogIds
slot, level, skillIdThe slot, and the level and skill ID of the asset you picked for it

Call both before gameplay starts, ValidateCastMapping for both slots. If either fails, do not start the run. ValidateSkillCatalog throws ArgumentNullException for a null list.

SituationSafeMessage
The run is missing the Weapon or Character skill"The admitted character must freeze both active cast skills."
An ID is duplicated or not in SkillCatalogIds"Authored skills must map uniquely to the admitted catalog."
The list is empty"The authored gameplay skill catalog is empty."
The asset's level or skill ID does not match the slot"The authored cast level and UUID must match the admitted slot."

Track reward enemiesโ€‹

ProjectVGameplayDataResult RegisterDropOwner(object owner, string slotId, long activationLease, out ProjectVGameplayDropRegistration registration)
ProjectVGameplayDataResult RecordDropDefeated(ProjectVGameplayDropRegistration registration)
ProjectVGameplayDataResult UnregisterDropOwner(ProjectVGameplayDropRegistration registration)
ParameterValue
ownerThe enemy object, for example its component; the same object every time
slotIdThe SlotId you placed on the enemy, from Drops
activationLeaseA number that changes on every activation of this enemy, such as a spawn counter
registrationSet on success; keep it for the other two calls

Register a reward enemy each time it becomes active (again with a new activationLease when a pooled enemy is reused), record its death, and unregister it when it is deactivated. RegisterDropOwner throws ArgumentNullException for a null owner.

SituationWhat happens
Unknown slotIdRegisterDropOwner fails (code PROJECTV_GAMEPLAY_DROP_SLOT_UNKNOWN)
Two enemies share a slot, or one enemy registers two slotsIt fails (code PROJECTV_GAMEPLAY_DROP_SLOT_OWNER_DUPLICATE), and those slots are left out of the result, even if a death was recorded
A registration from an older activationRecordDropDefeated and UnregisterDropOwner fail (code PROJECTV_GAMEPLAY_DROP_LEASE_STALE)
The death was already recordedRecordDropDefeated fails (code PROJECTV_GAMEPLAY_DROP_ALREADY_DEFEATED)
The enemy is deactivated after it diedUnregisterDropOwner does not cancel the recorded death; it still counts
The result was already sentRegisterDropOwner and RecordDropDefeated fail: "The gameplay completion is already fixed."

These failures need no message for the player; log them while you build the scene.

Record skill usesโ€‹

ProjectVGameplayDataResult RecordSkillActivated(ProjectVGameplaySkillId skillId, ProjectVGameplayDataSkillSlot slot)
ParameterValue
skillIdThe SkillId of the matching slot in CastSkills
slotCharacter or Weapon

Call it each time the player uses a skill. It throws ArgumentOutOfRangeException for an undefined slot.

SituationSafeMessage
The skill does not match the slot in CastSkills"This skill is not admitted for this slot."
ObserveOutcome was already called"The observed run outcome no longer accepts skill activations."

Report the outcomeโ€‹

ProjectVGameplayDataResult ObserveOutcome(ProjectVGameplayDataOutcome outcome, long? enemiesDefeated)
ParameterValue
outcomeSucceeded (win), Failed (loss) or Abandoned (give up)
enemiesDefeatedThe kill count, from 0 to 1024; null when you do not count it. Star objectives that need a kill count then fail

Call it when the run ends, before CompleteObservedAsync. It locks the run: from then on RecordSkillActivated and CancelRunAsync are refused.

SituationSafeMessage
An undefined outcome, or a kill count outside 0โ€“1024"The gameplay outcome or kill count is invalid."
The result was already sent with a different outcome"The gameplay completion is already fixed." (the same outcome again succeeds)

Send picked-up rewardsโ€‹

ProjectVGameplayDataResult SetClientRewards(IReadOnlyList<ProjectVGameplayClientRewardView> rewards)

Only for trust client rewards. Call it before CompleteObservedAsync; a later call replaces the list. It fails with "The gameplay completion is already fixed." once the result was sent, and throws ArgumentNullException for a null list or ArgumentException for a null entry.

End the runโ€‹

Task<ProjectVGameplayDataResult> CompleteObservedAsync(float currentHealth, int currentArmor, long rawSessionExperience, CancellationToken cancellationToken)
ParameterValue
currentHealthHealth at the end. Rounded and kept between 0 and the starting maximum; must be a finite number
currentArmorArmor at the end; kept between 0 and the starting maximum
rawSessionExperienceEXP earned in the run before any bonus; 0 or more
cancellationTokenStops waiting for the server

The SDK builds the result from what you reported and sends it:

  • On a win, no defeated reward enemies are sent; the server grants every reward enemy of the run.
  • On a loss or give-up, the defeated reward enemies are sent, but only up to the first Stage that still has an undefeated reward enemy.
  • Shared slots (above) are left out.

The first call fixes the result. A retry after a failure resends that same result and ignores the new values.

Returns a ProjectVGameplayDataResult:

IsSuccessWhat to show
trueRewards (what the server granted), MailedRewards (the part sent to the mailbox because it did not fit) and Stars (null for a loss, a give-up or a mission without stars)
falseSafeMessage, with a Retry button. Keep the result screen open

When it fails:

SituationSafeMessage
ObserveOutcome was not called, health is not a finite number, or EXP is negative"Observe a valid outcome and gameplay sample before completion."
The values cannot form a valid result"The admitted gameplay sample is invalid."
The presenter was unbound"The gameplay data binding no longer belongs to the active run."
The server refused, or no connectionThe server's or the network message. Retry with the same call

Show only the rewards the result returns. Keep your local run data until IsSettlementConfirmed is true.

TryGetHealthPercent(currentHealth, out percent) gives the health as a percentage of the starting maximum (0 to 100) for a result preview, the same way the result is built; it returns false for a health that is not a finite number.

Restartโ€‹

Task<ProjectVGameplayDataResult> CancelRunAsync(CancellationToken cancellationToken)

For Pause > Restart: ends the run without a result and without spending stamina. Start the mission again from Campaign.

SituationSafeMessage
ObserveOutcome was already called"The observed run outcome must be settled, not cancelled." End the run with CompleteObservedAsync instead
The presenter was unbound"The gameplay data binding no longer belongs to the active run."

A give-up is not a restart: report it as Abandoned and end the run, before leaving the scene.

Rulesโ€‹

  • Stamina is charged when the run ends: a win, a loss or a give-up. A restart, a run released from Campaign, or a killed or crashed app costs nothing.
  • Stars are scored by the server for a won run. Show them from result.Stars.
  • Pickups and temporary combat levels do not change the result, unless trust client rewards is on.
  • If the result fails or is unknown, keep the result screen open with Retry and call the same method again.

Trust client rewardsโ€‹

When ProjectVSettings.TrustClientRewards is on, the game sends the rewards the player picked up itself. Build the list of pickups and EXP items as ProjectVGameplayClientRewardView(itemId, amount) entries (non-empty ID, positive amount) and call SetClientRewards(list) before CompleteObservedAsync. With CompleteAsync, pass the same list in ProjectVGameplayCompletionView.ClientRewards. An empty list means "no rewards"; leaving it out means "not using trust mode". Check the setting yourself before sending a list.

The server only uses the list when trust mode is also turned on there (PROJECTV_MISSION_TRUST_CLIENT). It then grants exactly that list, ignores unknown IDs, and adds no other drops, EXP or win rewards; mission unlocks and stars still work as usual. If trust mode is off on either side, the server calculates rewards itself.

Common mistakesโ€‹

  • More than one consumer in the scene, including on inactive objects. Nothing is bound.
  • Spawning the locally selected character or using local stats. Use ActiveCharacterId and Stats.
  • Starting the run when a skill check failed. The result would not match the run; cancel it instead.
  • Reusing a pooled enemy without a new activationLease. A late callback from its earlier activation is then taken for the new one.
  • Putting one slot ID on two enemies. That slot is left out of the result.
  • Recording skill uses after ObserveOutcome. They are refused; report the outcome last.
  • Sending new values on Retry. The first CompleteObservedAsync fixed the result; just call it again.
  • Showing local loot instead of result.Rewards. Only the result is what the player got.
  • Using the presenter after UnbindGameplayData. Every call fails.

Presenterโ€‹

IProjectVGameplayDataPresenter is the run itself; IProjectVGameplayDataPolicy adds the helpers above on the same object.

MemberUse it for
OwnsAuthoritativeRunTrue while this presenter belongs to a started run that was not cancelled. Check it before ending the run
IsSettlementConfirmedTrue once the server confirmed the end of the run
RecordSkillActivated(skillId, slot)Record skill uses
CancelRunAsync(token)Restart
CompleteAsync(completion, token)End the run with a result you build yourself (below)

End the run with your own resultโ€‹

Task<ProjectVGameplayDataResult> CompleteAsync(ProjectVGameplayCompletionView input, CancellationToken cancellationToken)

Use it only if you do not use the policy helpers. Build a ProjectVGameplayCompletionView:

MemberValue
OutcomeSucceeded, Failed or Abandoned
RawSessionExperienceEXP earned before any bonus; 0 or more
DefeatedDropIdsSlot IDs of the reward enemies defeated; empty for a win
TelemetryA ProjectVGameplayTelemetryView: Health, StartingMaximumHealth, Armor, StartingMaximumArmor, DamageTaken, ShieldActivations, ShieldAbsorptions, all 0 or more
EnemiesDefeatedThe kill count, from 0 to 1024; null when not counted
ClientRewardsThe picked-up rewards for trust client rewards; null otherwise

It throws ArgumentNullException for a null input and ArgumentException for a bad value, such as negative telemetry or an undefined outcome. After a success, calling again returns the same result; after a failure, call again with the same input to retry. The result is the same as for CompleteObservedAsync.