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 use | It gives you |
|---|---|
IProjectVGameplayDataConsumer | You implement it once per mission scene; the SDK calls it with the run |
IProjectVGameplayDataPresenter | The run: record skill uses, end the run, restart |
IProjectVGameplayDataPolicy | Helpers on the same object: check skill assets, track reward enemies, build the result for you |
ProjectVGameplayStartingDataView | What to spawn: character, stats, skills, allies, reward enemies |
ProjectVGameplayDataResult | The 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.
| Member | Use it for |
|---|---|
ActiveCharacterId | The character to spawn. Spawn this one, not a locally selected one |
ActiveCharacterLevel | That character's level, 1 or higher |
Stats | Every stat for this run: health, attack, armor, energy, regen, move and attack speed, range. Apply all of them instead of local asset values |
MaximumHealth, MaximumArmor | The starting maximums (the same health and armor as Stats) |
SkillCatalogIds | The character's skill-tree IDs, to match your skill assets |
Skills | The skill-tree ranks for this run (SkillId, Rank) |
CastSkills | The Weapon and Character skills: Slot, Level (1 or higher) and the exact SkillId |
Allies | Teammates that join the run: CharacterId, Level, Experience, Stats, CastSkills. Empty when there are none |
Drops | Reward 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)
| Parameter | Type | Value |
|---|---|---|
presenter | IProjectVGameplayDataPresenter | The run. Keep it; cast it to IProjectVGameplayDataPolicy for the helpers |
startingData | ProjectVGameplayStartingDataView | What 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.
| Situation | What happens |
|---|---|
BindGameplayData throws | The run is cancelled and UnbindGameplayData is called |
| No component in the scene implements the consumer | Nothing is bound and an error is logged: "An admitted gameplay scene must contain exactly one data binding consumer." |
| Several components implement it | Nothing 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)
| Parameter | Value |
|---|---|
skillIds | The skill IDs read from your skill assets; each once, each in SkillCatalogIds |
slot, level, skillId | The 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.
| Situation | SafeMessage |
|---|---|
| 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)
| Parameter | Value |
|---|---|
owner | The enemy object, for example its component; the same object every time |
slotId | The SlotId you placed on the enemy, from Drops |
activationLease | A number that changes on every activation of this enemy, such as a spawn counter |
registration | Set 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.
| Situation | What happens |
|---|---|
Unknown slotId | RegisterDropOwner fails (code PROJECTV_GAMEPLAY_DROP_SLOT_UNKNOWN) |
| Two enemies share a slot, or one enemy registers two slots | It 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 activation | RecordDropDefeated and UnregisterDropOwner fail (code PROJECTV_GAMEPLAY_DROP_LEASE_STALE) |
| The death was already recorded | RecordDropDefeated fails (code PROJECTV_GAMEPLAY_DROP_ALREADY_DEFEATED) |
| The enemy is deactivated after it died | UnregisterDropOwner does not cancel the recorded death; it still counts |
| The result was already sent | RegisterDropOwner 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)
| Parameter | Value |
|---|---|
skillId | The SkillId of the matching slot in CastSkills |
slot | Character or Weapon |
Call it each time the player uses a skill. It throws
ArgumentOutOfRangeException for an undefined slot.
| Situation | SafeMessage |
|---|---|
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)
| Parameter | Value |
|---|---|
outcome | Succeeded (win), Failed (loss) or Abandoned (give up) |
enemiesDefeated | The 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.
| Situation | SafeMessage |
|---|---|
| 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)
| Parameter | Value |
|---|---|
currentHealth | Health at the end. Rounded and kept between 0 and the starting maximum; must be a finite number |
currentArmor | Armor at the end; kept between 0 and the starting maximum |
rawSessionExperience | EXP earned in the run before any bonus; 0 or more |
cancellationToken | Stops 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
Stagethat 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:
IsSuccess | What to show |
|---|---|
true | Rewards (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) |
false | SafeMessage, with a Retry button. Keep the result screen open |
When it fails:
| Situation | SafeMessage |
|---|---|
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 connection | The 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.
| Situation | SafeMessage |
|---|---|
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
ActiveCharacterIdandStats. - 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
CompleteObservedAsyncfixed 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.
| Member | Use it for |
|---|---|
OwnsAuthoritativeRun | True while this presenter belongs to a started run that was not cancelled. Check it before ending the run |
IsSettlementConfirmed | True 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:
| Member | Value |
|---|---|
Outcome | Succeeded, Failed or Abandoned |
RawSessionExperience | EXP earned before any bonus; 0 or more |
DefeatedDropIds | Slot IDs of the reward enemies defeated; empty for a win |
Telemetry | A ProjectVGameplayTelemetryView: Health, StartingMaximumHealth, Armor, StartingMaximumArmor, DamageTaken, ShieldActivations, ShieldAbsorptions, all 0 or more |
EnemiesDefeated | The kill count, from 0 to 1024; null when not counted |
ClientRewards | The 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.