Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ reader hits in production with nowhere to look it up.
considered) and link to it from everywhere it's relevant. Don't re-litigate it inline on
every page that touches it.

### Document each component on its own page

A page about a component states facts about that component. Name another component only when the
reader can't use this one without it. A link breaks the build when its target goes away, but a
sentence describing another component stays on the page, wrong, after that component changes.

- Name what a component builds on, such as its base class or a type it calls, where the reader
needs it, and link to the section that documents it instead of restating it.
- Don't list the subclasses or users of a component, and don't use them as examples ("as `X`
does"). Overview pages and the sidebar show what's built on what.
- Don't describe the private members of another class. If a page needs a fact about another
component, add it to the page of that component and link to it.
- Exceptions: a type in the public API of the component (a parameter, a return value, a field it
exposes or composes), an overview or decision page whose subject is how components relate, and a
how-to naming the components its task uses.

## Writing style: sound human, say only what's needed

Docs written by an LLM have a recognizable smell, and readers trust content less once they
Expand Down
15 changes: 6 additions & 9 deletions docs/core/core-concepts/process.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ sidebar_position: 4

`Tsvrc.Core.Process` is the base class for networked work that one player runs at a time: it
starts, runs, then stops or completes. That player is the owner, and another player takes over if
the owner leaves or is suspended. Its generated shadow is `TsProcess`. `PlayerTracker`,
`AutoPlayerTracker`, `ReadyCheckProcess`, `TsvrcTimer` and the `DataTransferer` chain all extend it.
the owner leaves or is suspended. Its generated shadow is `TsProcess`.

## Usage

Expand Down Expand Up @@ -45,9 +44,9 @@ gameRound.CompleteProcess();
`OnProcessStarted`, sends the synced state and starts the [tick loop](#the-tick-loop). On any other
player it sends `RequestStartProcess` to the owner and changes nothing locally. It never changes who
owns the object. A subclass that should run on the player who starts it calls `SetProcessOwner`
first, as `TsvrcTimer.StartTimer` and `PlayerTracker.StartPlayerTracking` do. If two players start
such a subclass at the same time, both can take ownership and start a run before either sees the
other, and only one stays the owner, so start it from one player when that can happen.
before `StartProcess`. If two players start such a subclass at the same time, both can take
ownership and start a run before either sees the other, and only one stays the owner, so start it
from one player when that can happen.

`StopProcess()` ends a run early and `CompleteProcess()` ends it successfully. The owner acts on
them directly, and any other player sends `RequestStopProcess` or `RequestCompleteProcess` to the
Expand Down Expand Up @@ -87,11 +86,9 @@ of its GameObject. Subclasses read and change it with three `protected` methods:

A run keeps going when its owner can no longer drive it:

- When the owner leaves, [VRChat picks a new
- When the owner leaves, [VRChat assigns a new
owner](https://creators.vrchat.com/worlds/udon/networking/ownership/#how-ownership-works). That
player takes over from `OnPlayerLeft` or `OnOwnershipTransferred`, whichever runs once it owns the
object. The two [can fire in either
order](https://feedback.vrchat.com/udon/p/order-of-onownershiptransferred-and-onplayerleft-when-another-player-leaves-the).
player takes over from `OnPlayerLeft` or `OnOwnershipTransferred`.
- When the owner is [suspended](https://creators.vrchat.com/worlds/udon/players/#get-issuspended),
their client stops running Udon and answering network events, so nothing would drive the run.
Every client that sees the suspension in `OnPlayerSuspendChanged` calls `SetProcessOwner` for its
Expand Down
10 changes: 4 additions & 6 deletions docs/core/explanations/sync-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ sidebar_position: 3

# Decision note: the shared-memory and sync model

Every Core type that replicates state, from [`TsvrcMemory`](../networking-data/tsvrc-memory) to
[`Process`](../core-concepts/process) and the types built on it such as
[`TsvrcTimer`](../networking-data/tsvrc-timer), uses the same two pieces: the VRChat manual sync mode
Every Core type that replicates state, such as [`TsvrcMemory`](../networking-data/tsvrc-memory)
and [`Process`](../core-concepts/process), uses the same two pieces: the VRChat manual sync mode
(`BehaviourSyncMode.Manual`) and an explicit `RequestSerialization()` call after each change. This
note explains why.

Expand Down Expand Up @@ -57,6 +56,5 @@ The types differ in how a write from another player reaches the owner:

- `TsvrcMemory` takes ownership for the local player before it writes a synced key.
- `Process` never takes ownership by itself. A lifecycle call from another player is sent to the
owner over the network, and the owner applies it.
- Subclasses that should run on the player who starts them, such as `TsvrcTimer` and
`PlayerTracker`, take ownership with `SetProcessOwner` before starting.
owner over the network, and the owner applies it. A subclass that should run on the player who
starts it [takes ownership first](../core-concepts/process#lifecycle).
60 changes: 24 additions & 36 deletions docs/core/game-flow/ranked-game-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,11 @@ sidebar_position: 2
`Tsvrc.Session.RankedGameSession` composes the rest of the tracking and timing stack into a
full round-based game session: a lobby, a synchronized ready-check-gated start, an active
game phase with completion tracking, and a configurable end condition. Its generated shadow
is `TsRankedGameSession`. It's one of the most dependent runtime types in the package: it's
built directly on [`PlayerTracker`](../players-tracking/player-tracker),
[`ReadyCheckProcess`](../players-tracking/ready-check-process), and
[`TsvrcTimer`](../networking-data/tsvrc-timer), so those three pages are real prerequisites,
not just related reading. It has no relationship to [`StateManager`](./state-manager) on the
page before this one; its own `CurrentState` is a plain field, not a `StateManager`
instance. See [Run a full timed round with a lobby](../how-to/running-a-timed-round) for
a task-oriented walkthrough.
is `TsRankedGameSession`. It's built from [`PlayerTracker`](../players-tracking/player-tracker),
[`ReadyCheckProcess`](../players-tracking/ready-check-process) and
[`TsvrcTimer`](../networking-data/tsvrc-timer) instances, listed under [What it's built
from](#what-its-built-from). See [Run a full timed round with a lobby](../how-to/running-a-timed-round)
for a task-oriented walkthrough.

## Getting an instance

Expand Down Expand Up @@ -91,17 +88,15 @@ logs an error and does nothing if a session is already running (`CurrentState` i
`Idle`). It takes an optional `additionalKnownPresentPlayerIds`: extra IDs unioned into that
initial snapshot even if the synced state of `_lobbyTracker` hasn't caught up to them yet,
since a caller's own local knowledge (say, a trigger volume's roster) can be ahead of what's
propagated over the network. Starting with an empty roster is rejected outright with an
error, rather than allowed to silently hang: `ReadyCheckProcess.CheckAllPlayersReady` never
auto-completes on a zero-length tracked list, so an empty-lobby start would otherwise leave
the session stuck in `Loading` forever with nothing able to complete it except a manual
`StopSession`.
propagated over the network. Starting with an empty roster logs an error and does nothing:
[a ready check with no players never
completes](../players-tracking/ready-check-process#edge-cases), so the session would otherwise
stay in `Loading` until a `StopSession` call.

`AddLoadingParticipant` lets a player join an *already-running* ready check that they
weren't part of the original snapshot: useful for a game that becomes eligible to more
players over time without waiting for the next round. It's a no-op outside `Loading` and for
a player already tracked, matching the underlying `AddTrackedPlayers` semantics it forwards
to.
players over time without waiting for the next round. It does nothing outside `Loading` or
for a player already tracked.

Once `InGame`, `RemoveGamePlayer` drops a player from `_gameTracker` directly, for a player
leaving the round early without leaving the instance (a respawn, an exit trigger); it's a
Expand All @@ -118,31 +113,24 @@ Four independent, individually toggleable natural end conditions
(`_endOnTimerComplete`, `_endOnAllGamePlayersLeft`, `_endOnAllPlayersCompleted`,
`_stopOnEmptyLobbyDuringLoading`) plus an explicit `StopSession()` call. Each maps to its own
`RankedGameSessionEndReason` value, readable via `LastEndReason` once the corresponding event
fires. `_stopOnEmptyLobbyDuringLoading` exists for the same underlying reason the empty-lobby
start guard does: `CheckAllPlayersReady` can't complete a check with nobody left tracked, so
losing every remaining player during `Loading` needs an explicit stop or the session hangs
there permanently, one phase earlier than the equivalent `_endOnAllGamePlayersLeft` check.
fires. `_stopOnEmptyLobbyDuringLoading` exists for the same reason as the empty-roster start
guard: a ready check whose players all left never completes, so without it the session would stay
in `Loading`. It's the `Loading` counterpart of `_endOnAllGamePlayersLeft`.

**`StopSession` reaches every client during `Loading`, but not during `InGame`.** The two
branches behave differently, and the difference matters if you're building a networked
"force stop" button.

During `Loading`, `StopSession` calls `_readyCheck.StopReadyCheck()`, an ordinary `Process`
call: it forwards to the real owner if the caller isn't it, and the owner's stop already
broadcasts to every instance player, the same way every other `ReadyCheckProcess`/
`PlayerTracker` state change does. `RankedGameSession` subscribes to that broadcast
(`OnReadyCheckStoppedEvent`), so every client's own bookkeeping updates on its own once it
arrives, with no extra work from you.

During `InGame`, `StopSession` instead calls `_EndSession` directly: `CurrentState`,
`LastEndReason`, and `OnSessionStoppedEvent` are all set and fired immediately and
synchronously, on the calling client only, before `_StopSubProcesses()` even asks
`_gameTracker`/`_completedTracker`/`_timer` to stop (and each of those calls is itself
subject to the same owner-forwarding as any other `Process` call). Those three sub-processes
do eventually stop and broadcast that to every client once their real owner processes it,
but `RankedGameSession` isn't subscribed to any of *their* stop events, only to their
players-added/players-removed/timer-completed events, so nothing re-runs its own bookkeeping
on other clients when that stop arrives. **A client that didn't call `StopSession` itself
During `Loading`, `StopSession` calls `_readyCheck.StopReadyCheck()`. `RankedGameSession`
subscribes to `OnReadyCheckStoppedEvent`, which fires on every client when the ready check stops,
so every client updates its own state when that event arrives.

During `InGame`, `StopSession` calls `_EndSession` directly instead: `CurrentState`,
`LastEndReason` and `OnSessionStoppedEvent` are set and fired right away, on the calling client
only, before `_StopSubProcesses()` asks `_gameTracker`, `_completedTracker` and `_timer` to stop.
`RankedGameSession` doesn't subscribe to the stop events of those three, only to their
players-added, players-removed and timer-completed events, so nothing updates its state on other
clients when they stop. **A client that didn't call `StopSession` itself
during `InGame` never sees its own `CurrentState` return to `Idle`.**

`RankedGameSession` carries no authorization or broadcast policy of its own (who's allowed
Expand Down
2 changes: 1 addition & 1 deletion docs/core/how-to/transferring-data-between-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,5 @@ the exact guarantees (what "completed" means when the recipient list shrank, for

VRChat caps individual network event payloads well below what most real payloads need, so a
single call has to become a sequenced chunk transfer underneath. The
[chain overview](../networking-data/overview.md) explains why that's split across eight small
[chain overview](../networking-data/overview.md) explains why that's split across several small
layers instead of one large class.
26 changes: 14 additions & 12 deletions docs/core/how-to/wiring-a-ready-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,33 @@ How to block an action until every present player has confirmed they're ready, u
```

3. Wherever your own UI lets a player mark themselves ready, call
`_readyCheck.SetReady()` (or `SetReady(false)` to un-ready). Only the calling player's
own status can be changed this way; see the reference page for why.
`_readyCheck.SetReady()` (or `SetReady(false)` to take it back). It only changes the status of
the calling player, see [the reference
page](../players-tracking/ready-check-process#ownership-and-changes-to-the-ready-set) for why.

## Handling the roster changing mid-check

If a player can join or leave while the check is already running, restart it with the
current roster rather than trying to patch the running one: `ReadyCheckProcess` doesn't
support adding players to a check already in progress.
A player who leaves or is suspended is [removed from the check
automatically](../players-tracking/player-tracker#automatic-removal). To include a player who
joins while the check runs, add them to it. They start out not ready:

```csharp
public void _OnRosterChanged()
{
if (_readyCheck.IsProcessRunning())
{
_readyCheck.StopReadyCheck();
_readyCheck.AddTrackedPlayers(_roster.LastAddedPlayerIds);
return;
}

_readyCheck.StartReadyCheck(_roster.LastPlayerIds);
}
```

For a fixed, known set of players instead of "everyone currently in the instance," start
`PlayerTracker`/`ReadyCheckProcess` directly with that explicit ID list and skip
`AutoPlayerTracker` entirely.
Restarting the check with the current roster also works, but clears every ready mark.

For a fixed, known set of players instead of everyone in the instance, start the ready check with
that ID list and skip `AutoPlayerTracker`.

Declare each `[WirePool]` field exactly once in the whole project if you want one shared
roster and ready check. [Pool slot counts are computed per
Expand All @@ -86,8 +89,7 @@ field of the same type.

## Why this shape

`ReadyCheckProcess` only tracks readiness for players it's already tracking. See
[its reference page](../players-tracking/ready-check-process) for the ordering guarantees
around `SetReady` and what happens when the tracked set changes mid-check. For the "why
See the [`ReadyCheckProcess` reference page](../players-tracking/ready-check-process) for the
ordering of `SetReady` and what happens when the tracked set changes during a check. For the "why
generated code, why not just call these directly at compile time" background, see
[How Core fits together](../core-concepts/how-it-fits-together).
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ sidebar_position: 2
`Tsvrc.DataTransfer.ChunkedTransferSession` sends a message split by [DataChunker](./data-chunker)
one chunk at a time: each chunk runs its own [ready
check](../../players-tracking/ready-check-process), in which every target player acknowledges it,
before the next chunk starts. It has no networking of its own, which `DataSender` adds on top. This
layer only owns the chunk sequence. You never call it directly. See
[DataTransferer](./data-transferer) for the type you use.
before the next chunk starts. It only owns the chunk sequence and sends nothing over the network
itself. It's one layer of [the data transfer chain](../data-transfer-overview), which names the type
to use.

## The sequence

Expand All @@ -26,11 +26,10 @@ to broadcast the transitions:
- `OnDataChunkSendRequested(dataChunk, chunkIndex, totalChunks, playerIds)`: fires for every chunk,
and is where a subclass sends it over the network.

If every tracked player leaves or is suspended during a ready check, the check can't complete:
[`ReadyCheckProcess`](../../players-tracking/ready-check-process) doesn't treat an empty tracked
list as everyone being ready. `ChunkedTransferSession` overrides `OnProcessUpdate` to find a running
process with no tracked players and calls `CancelDataTransfer()`, so the transfer doesn't wait
forever.
If every tracked player leaves or is suspended during a ready check, [the check can't
complete](../../players-tracking/ready-check-process#edge-cases). `ChunkedTransferSession`
overrides `OnProcessUpdate` to find a running process with no tracked players and calls
`CancelDataTransfer()`, so the transfer doesn't wait forever.

## Between chunks

Expand Down
20 changes: 10 additions & 10 deletions docs/core/networking-data/data-transfer/data-chunk-receiver.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ sidebar_position: 4
stored, and acknowledged. It overrides
`OnDataChunkSendRequested` from [DataSender](./data-sender) to broadcast each chunk via its own `[NetworkCallable]` method,
`BroadcastDataChunkReceived`, which every instance player receives and which validates the
chunk before storing it. You never call it directly. See
[DataTransferer](./data-transferer) for the type a consumer actually uses.
chunk before storing it. It's one layer of [the data transfer chain](../data-transfer-overview),
which names the type to use.

## Validation, layer by layer

Expand All @@ -30,8 +30,8 @@ flow, not just handling malformed input:
- **Sender must match the expected process owner** — an internal `_expectedSenderPlayerId`,
captured in `OnTransferStarted` from `Networking.LocalPlayer` (if the local instance is
the process owner) or from `NetworkCalling.CallingPlayer` (if not), rejects a chunk from
anyone else. The owner's own broadcast bypasses this via the same inline-call flag pattern
used elsewhere in the chain (here, `_isSendingChunk`).
anyone else. The owner skips this check during its own broadcast, tracked by the
`_isSendingChunk` flag.
- **`playerIds` must be non-null and include the local player** — otherwise the chunk isn't
meant for this client and is ignored.
- **`dataChunk` must be non-null** — `StringBuilder.Append(null)` is a silent no-op, so a
Expand All @@ -54,11 +54,11 @@ flow, not just handling malformed input:
ownership-transfer race). Without de-duplication a repeat would re-fire `OnChunkStored`
and send a redundant acknowledgment.

## A second reason for the same rate limit
## Why the rate limit is 100 per second

[`DataSender`](./data-sender) explains why every broadcast in this family shares
`maxEventsPerSecond: 100`: event ordering. `BroadcastDataChunkReceived` has an additional,
independent reason of its own: VRChat [splits any event payload over 1 KB into internal 1 KB
`BroadcastDataChunkReceived` uses `maxEventsPerSecond: 100`, the same limit as the [broadcasts it
must stay in order with](./data-sender#why-the-rate-limit-is-100-per-second). It has a second reason
of its own: VRChat [splits any event payload over 1 KB into internal 1 KB
packets](https://creators.vrchat.com/worlds/udon/networking/events/#parameter-size-limits-and-event-splitting),
and each of those counts separately against the rate limit. A 2,500-character ASCII
chunk is already close to 2,500 bytes (roughly three internal packets); a CJK chunk near the
Expand All @@ -73,8 +73,8 @@ of about 11 KB/s, which ends up being the real limit on how fast a transfer can

A validated chunk is stored at `_receivedChunks[chunkIndex - 1]`, `OnChunkStored` fires
(subclass hook for updating public properties), and only then does `NotifyChunkReceived()`
run, which is just `SetReady()` under the hood, acknowledging via the same ready-check
mechanism the rest of the chain already uses. `OnChunkStored` firing *before* the
acknowledge the chunk by calling the inherited
[`SetReady()`](../../players-tracking/ready-check-process#usage). `OnChunkStored` firing *before* the
acknowledgment matters on the owner specifically: `SetReady` can synchronously cascade into
completing the whole ready check, so emitting the chunk-received signal first guarantees a
chunk event is never observed to arrive after the completion event it made possible.
Expand Down
Loading
Loading