Live · guide
The gameplay bridge
Where doc 28's rules meet doc 27's storage — the third player identity, and four views that answer in the frame and write down afterwards without ever awaiting a grain.
Edit this page on GitHubDocuments
- IGameplayIdentity
- GameplayIdentityMap
- LedgerBridge
- PendingWrite
- BridgeRefusal
- LockoutBridge
- PendingLockout
- SocialBridge
- GuildEdit
- GuildEditKind
- PendingGraph
- SocialLink
- PlayerProfile
- ProfileSectionId
- ProfileSections
- IProfileSection
- ProfileBinder
- ProfileFormatException
- CheckpointPolicy
- CheckpointReason
- ProfilePityStore
- ProgressionSection
- QuestSection
- ExplorationSection
- WardrobeSection
What it is#
Vixen.Live.Gameplay is the only assembly that references both halves of the engine's server story:
the gameplay libraries of doc 28 and the persistence and cluster of doc 27. It exists because the
layer rules make it the only place that can — Live/ may reference Gameplay/, nothing in
Gameplay/ may reference Live/, so the join has nowhere else to live.
It is five things:
| | |
|---|---|
| IGameplayIdentity · GameplayIdentityMap | The third player identity, joined to the other two. |
| LedgerBridge | Doc 28's IEconomyLedger answered in the frame, written through doc 27's journal. |
| LockoutBridge | Doc 28's ILockoutStore over the fleet-wide IInstanceGrain. |
| SocialBridge | Doc 28's ISocialStore over IGuildGrain, and friends and blocks. |
| PlayerProfile · CheckpointPolicy | PlayerRecord.Profile given a shape, and when it is written. |
| ProgressionSection · QuestSection · ExplorationSection · WardrobeSection · ProfilePityStore | The five codecs that fill it. |
What it is for#
Three player identities, and only two were joined#
There are three, and the engine had joined the first two:
Vixen.Net.Sessions.PlayerId— auintthe session assigns.Vixen.Live.PlayerKey— twoGuids the database is keyed by.RealmPlayeralready joined these.Vixen.Gameplay.PlayerId— aulongevery rule in doc 28 passes around, and the one every- durable write starts from.
GameplayIdentityMap is that join, and it is a widening rather than a hash: a gameplay id *is*
its session id in a wider integer, populated as a table at admission. Hashing a 256-bit PlayerKey
into 64 bits collides, and two players who collided would write each other's inventory.
⚠ A gameplay PlayerId is realm-scoped and must never reach the database. Doc 28 calls one
*"stable for as long as a party invite or a mute list has to be"*, and those are not the same length:
a party lasts a session, a mute list outlives every realm the player will ever be on. A saved row
carrying a raw gameplay id means somebody else on the next realm.
A view in the frame, an outbox behind it#
The three bridges have one shape, and ADR-016 is why: *"Orleans is asked, not awaited."* Every interface here is synchronous, because a purchase, a zone-in and a guild-chat line all ask it mid-frame, and the thing that owns the true answer is a round trip. So each bridge answers from an in-memory view and posts the change to an outbox the realm drains off the frame path.
⚠ A drained write is not a removed write. Drain() returns what is waiting and leaves it there;
Settle is what takes it out. In flight is not done, and losing a ledger intent is losing an item.
Assets go in the ledger, counters go in the profile#
That one sentence decides the storage of everything a character owns, and it decides it from a property of the data rather than from taste. An asset can be duplicated, so every movement of one is a journal row with an idempotency key. A counter cannot — writing level 42 twice leaves you at 42 — so a counter is written on a cadence, and a crash loses at most one interval.
⚠ That loss is the correct trade and not a compromise: making a counter durable per kill puts a
database round trip on the combat path. It is also why ProfilePityStore can be synchronous where
LedgerBridge could not.
What each bridge is afraid of#
Each one is arranged around a different way of being wrong, and they are worth reading as three answers to the same question — *what does a partial view get wrong, and how loud is it?*
LedgerBridge— the balance that disagrees. The projection is authoritative for the frame and- the database is the audit trail, which is not optimism: the lease already says exactly one realm
- writes. So
Insufficientcoming back from the database is a defect and not a refusal — the - projection checked first, and disagreement means the single-writer property broke. It is counted in
-
Divergences, raised, and kept as evidence. AndSupersede()is not an undo: ADR-021 buffers and - re-flushes, so every waiting write is *restamped* when the lease returns.
LockoutBridge— the absence that reads as permission. An unknown balance reads as zero and- refuses a purchase, which is annoying and safe. An unknown *lockout* reads as
null, and -
ILockoutStore.Finddefinesnullas "not locked" — so a player whose lockouts were never - loaded is admitted to a raid they are already saved to, and the run they get is one the fleet
- cannot take back. That is doc 28's *"a lockout one shard knew about is a lockout a player evades by
- zoning"* reopened from inside. The interface cannot say *"I do not know"*, so
IsWarmis what - admission checks and a cold
FindincrementsColdReadsand raisesCold.
SocialBridge— the roster that is only partly here. A 500-member guild has maybe thirty- members online, and a member who is not connected *to this realm* has no gameplay id at all, so
- they cannot be seated. That is the same partial view the other two keep — but it makes one thing
- lethal: a partial roster must never be written back as the whole truth, or the write deletes
- everybody who was offline.
Why a guild is written as operations and a graph is written as state#
SocialBridge.SaveGuild is implemented, counted in StateWrites, and writes nothing. Two things
are missing from a state-shaped save of a guild and neither can be recovered afterwards:
1. The roster is partial, so writing it down deletes the absent.
2. A diff of two rosters cannot say who did it — and every IGuildGrain method needs an actor,
because every guild rule is about authority.
Invite, Kick, Promote and Rename are the same operations with the actor kept, and each queues
a GuildEdit. A member this realm cannot name is in neither side of any operation, so they are never
touched — which is exactly the property that makes a partial roster safe.
A graph is state-shaped and safely so, and the difference is worth naming: it has one owner and every change in it is theirs, so handing over the whole thing throws nothing away. Only the nameable part is diffed; a tie to somebody this realm cannot name is carried through untouched.
⚠ A block on somebody offline is the case to get right. The person blocked is usually not here,
so the block lives in the durable set and in no graph — and SocialGraphs.IsSevered answers false,
which means they can whisper, invite and trade the moment they log in. SocialBridge.Admitted is the
sweep that seats them, and a realm that forgets to call it has a block that leaks.
An unknown profile section is preserved, never dropped#
PlayerRecord.Profile is opaque to the persistence layer on purpose — its schema is the game's.
PlayerProfile gives that blob a shape without giving persistence one: a map of ProfileSectionId
to bytes, written in id order.
⚠ The container knows nothing about types, and that is the whole point. Doc 27 § Upgrades
fragments a population by version deliberately, so during a rollout an old realm and a new realm both
write the same character. If the old one dropped the section the new one had added, a player who
zoned the wrong way would lose it — silently, and only some of the time. A map of id to bytes cannot
make that mistake, and the codecs that know types sit above it as IProfileSection.
Two further rules fall out: sections are written in id order, so two realms holding the same state produce the same bytes; and writing the same bytes back is not a change, or every checkpoint looks like one and the row is rewritten on a cadence for ever.
Using it#
A realm builds one identity map and one of each bridge it needs, fills them at admission, answers from them during the frame, and drains them afterwards.
var identity = new GameplayIdentityMap();var lockouts = new LockoutBridge(identity);var social = new SocialBridge(identity, library);// Admission: the join, then everything durable this player needs answered in a frame.var player = identity.Admit(key, session);lockouts.Warmed(player, await grains.Instances.LockoutsOf(key));social.Warmed(player, await persistence.Guilds.ReadAsync(guildId, cancellation));social.Warmed(key, storedLinks);social.Admitted(key, player);⚠ Warming with an empty list is meaningful. "Saved to nothing" and "nobody has asked" are the
same absence in a cache and must not be the same fact — which is why LockoutBridge.Warmed takes an
empty sequence and SocialBridge.Warmed takes a null row, and both mark the player warm.
Draining is the realm's own job, off the frame path, and settling is what removes a write:
foreach (var edit in social.Drain()) { var outcome = await grains.Guild(edit.Guild).Apply(edit); social.Settle(edit, outcome.Refusal);}⚠ A refusal does not roll the view back. Undoing a join two frames later is a player who was in
the guild, saw the roster, said hello and was silently ejected — and the next Warmed corrects it
from the authority anyway. What the realm owes is telling them, which is what Refused is for.
Registering a profile section#
IProfileSection is the seam that keeps this assembly from being a bundle. Doc 28's whole shape is
that every library is declinable, so a game that took quests and declined exploration should not
carry an exploration codec. A section registers itself, and whatever is never registered is still
preserved.
var binder = new ProfileBinder() .Add(new ProgressionSection(progression, checkpoint)) .Add(new QuestSection(journal, checkpoint)) .Add(new ExplorationSection(exploration, checkpoint)) .Add(new WardrobeSection(wardrobe, catalog.Tags, checkpoint)) .Add(new ProfilePityStore(checkpoint));binder.Load(PlayerProfile.Read(record.Profile));⚠ Two sections claiming one id are refused rather than last-wins. Section names are hashed, so a collision is possible; one section silently reading the other's bytes presents as a character whose quests are full of somebody else's fog.
Five are shipped: ProgressionSection, QuestSection, ExplorationSection, WardrobeSection and
ProfilePityStore. Each one loads through a seating method on its gameplay object rather than
through the rules that made the state, and every one of those seams was added for a failure the
rules would otherwise cause on login:
| Codec | What replaying the rules would do |
|---|---|
| ProgressionSection | SetLevel zeroes the experience towards the next level — every login. |
| ProgressionSection | Allocate re-validates a talent build, so a patch that moved a prerequisite wipes it with no refund and no message. |
| ProgressionSection | Train clamps to today's cap, so a patch that lowers one destroys the difference permanently — where the next Train clamps late enough to be reversible. |
| QuestSection | Accept asks the requirements, so a character who took a quest at level ten is asked again at level nine. |
| QuestSection | Replaying the objective advances announces every objective again and fires a reward chain twice. |
| ExplorationSection | Discover with a null context skips the requirements and *still* raises Found — a toast for every landmark ever visited, plus the map-complete fanfare. |
| WardrobeSection | Show refuses an appearance that has since been taken back, throwing the player's choice away for good. |
Two rules about what a codec may write down are worth reading together, because they are the same rule:
⚠ Nothing build-scoped goes in the bytes. A GameplayTag is an index into a pre-order walk of
the tag tree, so *adding one tag renumbers every tag after it* — WardrobeSection therefore writes
slot names. A gameplay PlayerId is a session id widened, so ProfilePityStore writes only the
loot table: a profile already names the character, and keeping the id would mean every lookup missing
after a transfer with the rows still sitting in the profile. A DefId is safe in both places,
because it is a hash of an address rather than a position in a table.
⚠ What this build does not understand is preserved, section by section and entry by entry. A
profession or a talent tree with no definition here is carried through; a quest's *history* is kept
for a quest this build has lost, because history is what QuestRepeat.Once reads and an id is all it
needs. The one deliberate exception is an *active* quest with no template: it has no stages, no
objectives and no tags, so there is nothing to hold — the bytes stay in the profile and it comes back
on a build that knows it.
The checkpoint#
CheckpointPolicy decides when the counters are written down.
⚠ **A failed write leaves it dirty *and* does not restart the clock. Clearing the flag loses the interval for good; restarting the clock means a store that is briefly unhappy is retried a whole cadence later, which turns a five-second outage into five minutes of lost progress.
⚠ Transfer and logout write only when there is something to write.** "Always on transfer" reads as unconditional and should not be: a character nobody changed has the same bytes stored, and a round trip to write them is a round trip inside the overlap window a transfer spends loading a map.
Examples#
A tick, in the order the pieces are meant to be used — ask, act, mark dirty, and let the drain happen somewhere else:
// Zone-in. IsWarm first, because Find cannot say "I do not know".if (!lockouts.IsWarm(player)) { return Refuse(player, "still loading");}if (lockouts.Find(player, instance, "heroic") is { } saved && saved.Completions > 0) { return Refuse(player, "already saved");}// A boss dies. The lockout is recorded here and written down later.lockouts.Save(new(player, instance, "heroic", reset, completions: 1));// Experience is a counter, so it moves in memory and the checkpoint decides when it lands.progression.Award(player, experience);checkpoint.Touch();if (checkpoint.Due(now, out var reason)) { Enqueue(new CheckpointRequest(player, reason));}Reading a lockout for somebody who was never warmed is the mistake the type is built to make loud, so wire the counter to something that notices:
lockouts.Cold += who => log.Error("Lockouts read cold for {Player} — they may have been let into a saved raid.", who);ledger.Diverged += divergence => log.Error("The ledger disagreed with the projection: {Divergence}", divergence);social.Cold += who => log.Warning("Guild read cold for {Player} — they will look unguilded.", who);⚠ LedgerBridge.Divergences should be zero in normal running; a non-zero value means the
single-writer property broke. SocialBridge.Divergences should not be zero — a cap measured
against a partial roster is expected to be wrong sometimes, and the trade is deliberate. What that
counter is for is noticing when the number stops looking like the online fraction of a roster.
See also#
- Durable state and the ledger — the journal, the repositories and the fence.
- Writing a realm — where the drain belongs in a tick.
- Economy —
IEconomyLedger, from the other side. - Social —
ISocialStore,Guild.SeatandSocialGraph.Seat. - Instances —
ILockoutStoreand what a lockout is.