Skip to content

Commit 0c5ebd6

Browse files
committed
Align EmoteStarted => EmoteStopped wrapping within a tick
Signed-off-by: Mikhail Agapov <mikhail.agapov@decentraland.org>
1 parent 438e1b8 commit 0c5ebd6

2 files changed

Lines changed: 28 additions & 18 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ Standard protobuf `optional` fields map to a plugin-generated field_mask on the
9595

9696
**No proactive STATE_FULL mid-session.** The client drives resync, the server never anticipates it.
9797

98-
**Snapshot History.** The server keeps a small rolling history of snapshots per subject. When a `RESYNC_REQUEST` arrives with the client's last known seq, and the seq is still in the ring, the server sends a targeted delta instead of a full snapshot.
98+
**Snapshot History.** The server keeps a small rolling history of snapshots per subject (`SnapshotBoard` ring buffer). Each snapshot carries positional/animation state and optional `EmoteState` metadata (emote ID, start tick, duration, stop reason). The ring is the single source of truth for all per-peer state — there is no separate emote board.
99+
100+
**Intermediate snapshot scanning.** Between two simulation ticks, multiple snapshots may be published (movement, teleports, emote starts/stops). The simulation scans all intermediates from `lastSentSeq+1` to `latestSeq`, collecting the **last** of each discrete event type (teleport, emote start, emote stop). Earlier events of the same type are superseded. An emote that started and stopped in the same batch is invisible to the observer.
101+
102+
**Resync.** Default: always responds with `STATE_FULL`. When `Peers.ResyncWithDelta` is enabled, the server first attempts a targeted delta from the client's `knownSeq` baseline (if still in the ring), falling back to `STATE_FULL` when evicted. Configurable via `appsettings.json`, Docker env var (`Peers__ResyncWithDelta`), or GitHub manual deploy input.
99103

100104
**Interest management** on the server limits which players receive updates about which other players. Per-observer fan-out is the primary bandwidth concern.
101105

@@ -113,19 +117,21 @@ Standard protobuf `optional` fields map to a plugin-generated field_mask on the
113117
- Tiered quantization based on interest management
114118

115119
**EMOTE_START** (ch0, reliable)
116-
- Emote string ID, emote type (one_shot / looping) is not transmitted, it is resolved from the DTO on the client side
120+
- Emote string ID, optional duration_ms, and full PlayerState
121+
- Server publishes a snapshot with `EmoteState` (emote ID, start tick, duration) — no separate emote board
117122
- Client stops sending MovementInput while emoting
118123

119124
**EMOTE_STOP** (ch0, reliable)
120-
- Looping emotes only; one-shots are terminated by the server timer
125+
- Looping emotes only; one-shots expire via server-side time check against `EmoteState.DurationMs`
126+
- Server publishes a stop snapshot (EmoteId=null, StopReason=Cancelled) preserving the subject's position
121127

122128
**TELEPORT_REQUEST** (ch0, reliable)
123129
- Client-initiated teleport (e.g. triggered by game logic)
124130
- Server validates and rebroadcasts as TELEPORT to all observers
125131

126132
**RESYNC_REQUEST** (ch0, reliable)
127133
- Sent when a received STATE_DELTA can't be applied (gap in seq)
128-
- Server responds with STATE_FULL
134+
- Server responds with STATE_FULL (or targeted delta when `Peers.ResyncWithDelta` is enabled)
129135

130136
### Server → Client
131137

@@ -140,12 +146,14 @@ Standard protobuf `optional` fields map to a plugin-generated field_mask on the
140146
- Quantized floats, same ranges as MovementInput
141147

142148
**EMOTE_STARTED** (ch0, reliable, broadcast to interest set)
143-
- Emote string ID, type, server_tick, and piggybacked anchor position (Vec3)
144-
- Anchor position sent reliably because no further position updates will arrive during the emote
149+
- Emote string ID, sequence, server_tick, and full PlayerState
150+
- PlayerState sent reliably because no further position updates will arrive during the emote
145151
- Observers use server_tick to scrub animation forward by transit latency
152+
- Only the last emote start per batch is broadcast; earlier ones superseded by the latest
146153

147154
**EMOTE_STOPPED** (ch0, reliable, broadcast to interest set)
148-
- Reason: completed (one-shot timer) or cancelled (client sent EMOTE_STOP)
155+
- Reason: completed (one-shot duration expired) or cancelled (client sent EMOTE_STOP)
156+
- Carries sequence and full PlayerState so the client can snap to the correct position on resume
149157
- Client resumes MovementInput only after receiving this (gates resume on server clock)
150158

151159
**TELEPORT** (ch0, reliable, broadcast to interest set)
@@ -168,8 +176,13 @@ Standard protobuf `optional` fields map to a plugin-generated field_mask on the
168176

169177
**Teleport as a separate message, not an is_instant flag.** Teleports are discrete events, not a property of continuous movement. Keeping them as a dedicated reliable message guarantees the interpolation-skip instruction arrives before subsequent position updates.
170178

171-
**server_tick is a single unified clock** across all messages (STATE_DELTA, EMOTE_STARTED, EMOTE_STOPPED, TELEPORT). Client uses it for animation scrubbing and dead reckoning. `peer->roundTrip
172-
` (available on both client and server via ENet) provides latency without requiring client_tick fields in packets.
179+
**server_tick is a single unified clock** across all messages (STATE_DELTA, EMOTE_STARTED, EMOTE_STOPPED, TELEPORT). Client uses it for animation scrubbing and dead reckoning. `peer->roundTripTime` (available on both client and server via ENet) provides latency without requiring client_tick fields in packets.
180+
181+
**Emote state inlined into PeerSnapshot.** `EmoteState` (emote ID, start tick, duration, stop reason) is a nullable struct on `PeerSnapshot`, stored in the ring buffer alongside positional data. No separate emote board — the snapshot ring is the single source of truth. EmoteStartHandler writes emote metadata directly into the snapshot. EmoteStopHandler publishes a stop snapshot. One-shot emote expiry is computed lazily from the view's cached duration at observation time, not eagerly mutated.
182+
183+
**Simulation loop: scan → broadcast → stop → delta.** Each tick, the simulation scans intermediate snapshots and collects the last teleport, emote start, and emote stop. Only the final event of each type is broadcast — intermediate positions or superseded emotes are discarded. An emote that started and stopped in the same batch is invisible to the observer. Discrete events (teleport, emote start) suppress the unreliable delta for that tick to prevent baseline races. Emote stop does not suppress delta — the client needs the position update on resume.
184+
185+
**Protobuf optional fields = field_mask on wire.** The schema expresses intent with `optional`. The plugin generates a compact bitmask for the wire. These are the same concept at different layers — the plugin bridges them.
173186

174187
**Protobuf optional fields = field_mask on wire.** The schema expresses intent with `optional`. The plugin generates a compact bitmask for the wire. These are the same concept at different layers — the plugin bridges them.
175188

src/DCLPulse/Peers/Simulation/PeerSimulation.cs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,6 @@ private PeerSnapshot ProcessExistingSubject(
270270
Dictionary<PeerIndex, uint>? resyncRequests)
271271
{
272272
PeerSnapshot lastSentState = view.LastSentSnapshot;
273-
var emoteStartedSent = false;
274273
var discreteEventSent = false;
275274

276275
// --- Phase 1: scan intermediates, collect last of each discrete event type ---
@@ -287,7 +286,8 @@ private PeerSnapshot ProcessExistingSubject(
287286
discreteEventSent = true;
288287
}
289288

290-
// --- Broadcast emote start (only if more recent than the last stop) ---
289+
// --- Broadcast emote start only if the emote is still active (not stopped in the same batch).
290+
// An emote that started and stopped between ticks is invisible to the observer. ---
291291
bool emoteStartIsEffective = lastEmoteStart.HasValue
292292
&& lastEmoteStart.Value.Seq > (lastEmoteStop?.Seq ?? 0);
293293

@@ -303,16 +303,13 @@ private PeerSnapshot ProcessExistingSubject(
303303
if (es.Seq > lastSentState.Seq)
304304
lastSentState = es;
305305

306-
emoteStartedSent = true;
307306
discreteEventSent = true;
308307
}
309308

310-
// --- Phase 2: sync emote stop (only if we didn't just start one) ---
311-
if (!emoteStartedSent)
312-
{
313-
PeerSnapshot? effectiveStop = !emoteStartIsEffective ? lastEmoteStop : null;
314-
TrySyncEmoteStop(observerId, entry.Subject, ref view, latestSnapshot, effectiveStop);
315-
}
309+
// --- Phase 2: sync emote stop (skip when the start is still effective —
310+
// either just sent, or already synced via dedup — the emote is active) ---
311+
if (!emoteStartIsEffective)
312+
TrySyncEmoteStop(observerId, entry.Subject, ref view, latestSnapshot, lastEmoteStop);
316313

317314
// --- Phase 3: resync or delta (skip if discrete events already carried full state) ---
318315
if (!discreteEventSent)

0 commit comments

Comments
 (0)