Service Purpose: Authoritative avatar synchronization server for Decentraland. Relays avatar state (position, rotation, animation, emotes, teleports) between players within a single Archipelago island over UDP/ENet. One Pulse instance = one island.
Role in the real-time layer: Pulse is one of two parallel connections a client maintains (the other is LiveKit). It handles high-frequency avatar state only — it has no awareness of scene entities, voice, or CRDT. The client's Pulse address is currently hardcoded; Archipelago does not yet return a dynamic Pulse endpoint.
Protocol: UDP / ENet on port 7777 Load balancer: AWS NLB (L4) — mandatory, ALB does not support UDP. Sticky 5-tuple routing ensures a client always reaches the same Pulse instance.
ENet channels:
| Channel | Reliability | Use |
|---|---|---|
| 0 | Reliable ordered | Handshake, emotes, teleports, resync responses, join/leave events |
| 1 | Unreliable sequenced | High-frequency position/animation deltas (~20 msg/s at Tier 0) |
| 2 | Unreliable unsequenced | Declared (ENetChannel.UNRELIABLE_UNSEQUENCED), no message currently targets it |
Local ECDSA auth chain validation — no network call per connection.
PENDING_AUTH → AUTHENTICATED → DISCONNECTING → [removed]
- Client sends
{ authChain, timestamp, connectSig }on channel 0 immediately after ENet connect - Server validates locally: chain structure → ephemeral key expiry → connectSig → timestamp within 60s → server_id match
PENDING_AUTHdeadline: 30 seconds. Non-handshake packets silently dropped.- Auth failure:
HANDSHAKE_REJECT { reason }thenenet_peer_disconnect_later - Server-initiated disconnects (handshake reject,
PeerDefensekick) sit inPENDING_DISCONNECT— transport disconnect requested, ENet event pending, inbound packets skipped — beforeDISCONNECTING player_id = chain[0].payload(Ethereum wallet address)
Defense-in-depth, all local, all fail-closed. Each has a dedicated class under Transport/Hardening/ or Messaging/Hardening/:
- Pre-auth admission (
PreAuthAdmission) — globalPreAuthBudgetreserves capacity for AUTHENTICATED peers; per-source-IPMaxConcurrentPreAuthPerIPprevents single-IP floods. - Handshake replay cache (
HandshakeReplayPolicy) — sliding-window(wallet, timestamp)rejection withinPendingAuthCleanTimeoutMs. Sweeps opportunistically at 50% capacity. - Handshake attempt cap (
HandshakeAttemptPolicy) — per-peer counter bounds repeated ECDSA recoveries. - Field validation (
FieldValidator) — bounds / NaN / Infinity / length checks on all client-supplied fields; violation disconnects with a specificDisconnectReason. - Movement input rate limit (
MovementInputRateLimiter) — per-peer token bucket (MaxHzsustained refill +BurstCapacityfor UDP-jitter tolerance); violation → instant disconnect withINPUT_RATE_EXCEEDED. - Discrete event rate limit (
DiscreteEventRateLimiter) — token bucket onEmoteStart/EmoteStop/Teleport; discard-on-violation, not backpressure. - Both rate limiters share
TokenBucketRateLimiter, which owns the bucket math; subclasses provide config + aPeerThrottleStateslot getter/setter.
Sliding window / time-based assumption. No ACK tracking for the unreliable channel. Server diffs current vs last_sent_snapshot per observer and sends the result. If the client can't apply a delta it sends RESYNC_REQUEST.
3-tier spatial LOD (base tick 50 ms, cadences from PeerOptions.SimulationSteps = {50, 100, 200}):
| Tier | Distance | Update frequency |
|---|---|---|
| 0 | ≤ 20m | Every tick (~20/s) |
| 1 | ≤ 50m | Every 2nd tick (~10/s) |
| 2 | ≤ 100m | Every 4th tick (~5/s) |
Players outside 100m (SpatialAreaOfInterestOptions.MaxRadius) receive no updates.
Realm partitioning. PeerSnapshot.Realm gates visibility inside the area-of-interest collectors: observers only see subjects whose Realm string matches exactly. A single Pulse instance transparently hosts multiple realms that never see each other. The subject's realm rides along on PlayerJoined (on entry) and Teleported (a teleport may cross realms), so the client always knows which realm a peer belongs to.
Snapshot history: Server keeps a small rolling ring of snapshots per subject (SnapshotBoard). RESYNC_REQUEST default response is STATE_FULL. When Peers.ResyncWithDelta is enabled, the server first attempts a targeted delta from the client's knownSeq baseline and falls back to STATE_FULL when that seq has been evicted from the ring.
SnapshotBoard ledger. Each snapshot carries positional/animation state plus nullable columns (EmoteState, Realm, …) that Publish carries forward from the previous slot when the incoming snapshot leaves them null — the latest ring entry is always self-sufficient. New per-peer state should extend this ledger (nullable PeerSnapshot column + carry-forward) rather than spawn parallel shared boards.
Other per-peer boards. IdentityBoard (PeerIndex ↔ Wallet, canonical identity source) and ProfileBoard (per-peer profile version, drives ProfileAnnouncement fan-out). Both are zeroed on cleanup.
AoI implementation. Default wiring is SpatialHashAreaOfInterest backed by SpatialGrid (Morton-encoded cells, 9-cell query neighborhood). SpatialAreaOfInterest is the linear-scan fallback used in tests.
Simulation tick (per worker, per peer it owns): scan intermediate snapshots since lastSentSeq and collapse to the last teleport / emote-start / emote-stop; broadcast discrete events; suppress the unreliable delta on teleport or emote-start (avoids baseline races); otherwise compute and send PlayerStateDelta.
Stale-view sweep. Every SWEEP_INTERVAL (≈100 ticks, ~5 s) PeerSimulation.SweepStaleViews prunes observer views for no-longer-visible subjects and emits PlayerLeft. Bounds memory and closes the same-wallet-reconnect "two views" window.
Self-mirror. When Peers.SelfMirrorEnabled=true, each peer receives its own state as if from another peer under SELF_MIRROR_WALLET_ID at tier SelfMirrorTier. Client-side animation testing aid. The self-mirror is injected outside the AoI, so it re-applies the same realm invariant itself: a peer with no realm yet (legacy connect, before its first teleport) is not mirrored until a realm is set.
Proto-level names: see the ClientMessage / ServerMessage oneof message in decentraland/pulse/*.proto. The tables below use on-wire message names.
| Message | Channel | Description |
|---|---|---|
Handshake |
0 (reliable) | Auth chain + timestamp + connectSig. First packet after transport connect. |
Input (MovementInput) |
1 (unreliable sequenced) | Full continuous state: position, velocity, rotation, blend values, head IK, state flags (u16 bitmask). Sent ~20/s while moving; paused during emotes. |
Resync (ResyncRequest) |
0 (reliable) | Sent when a PlayerStateDelta can't be applied (seq gap). Carries SubjectId, KnownSeq. |
ProfileAnnouncement (ProfileVersionAnnouncement) |
0 (reliable) | Client announces a new profile version; server rebroadcasts to observers as PlayerProfileVersionsAnnounced. |
EmoteStart |
0 (reliable) | EmoteId, DurationMs (optional), PlayerState. Client stops sending Input while emoting. |
EmoteStop |
0 (reliable) | Looping emotes only — one-shot emote expiry is computed lazily at observation time from the cached duration, not by a server-side timer. |
Teleport (TeleportRequest) |
0 (reliable) | Client-initiated teleport (ParcelIndex, Position, Realm). Server validates and rebroadcasts as Teleported. |
| Message | Channel | Description |
|---|---|---|
Handshake |
0 (reliable) | Auth accept/reject response. On reject, followed by enet_peer_disconnect_later. |
PlayerJoined |
0 (reliable, broadcast) | A peer entered the observer's interest set. Carries UserId, ProfileVersion, State and the subject's Realm (the AoI partition it belongs to). |
PlayerLeft |
0 (reliable, broadcast) | A peer left the observer's interest set (distance, realm change, disconnect, or stale-view sweep). |
PlayerStateFull |
0 (reliable) | Full snapshot of a subject. Sent on zone entry or in response to Resync. |
PlayerStateDelta |
1 (unreliable sequenced) | Delta from last_sent_snapshot. Optional-field presence suppresses unchanged fields. State flags always present. |
PlayerProfileVersionsAnnounced |
0 (reliable, broadcast) | Fan-out of ProfileAnnouncement to observers. |
EmoteStarted |
0 (reliable, broadcast) | SubjectId, Sequence, ServerTick, EmoteId, PlayerState. Full PlayerState sent reliably because no further position updates arrive during the emote. |
EmoteStopped |
0 (reliable, broadcast) | SubjectId, Sequence, ServerTick, Reason, PlayerState. Reason = completed (one-shot duration expired) or cancelled (client EmoteStop). PlayerState lets the client snap to the correct position on resume. |
Teleported |
0 (reliable, broadcast) | Authoritative position + ServerTick + the subject's Realm (a teleport may move the peer to a different realm). Client clears interpolation buffer and snaps. |
Custom protoc plugin (protoc-gen-bitwise) generates quantized-accessor partials (*.Bitwise.cs) from .proto files.
- Quantized fields are plain
uint32on the wire (ordinary protobuf varints), annotated withbits,min,max; generated float{Field}Quantizedaccessors encode viaencoded = round((clamp(v, min, max) - min) / (max - min) * (2^bits - 1))(power-law variant for velocity-style fields) optionalproto fields → native protobuf per-field presence (unchanged fields stay off the wire; no plugin-generated mask)- Static
Quantizehelpers (src/Protocol/Generated/Quantize.cs) implement the encoding
One Pulse instance per island. Island count grows with concurrent players (Archipelago creates new islands as needed). A single instance also shards across realms internally — see "Realm partitioning" above.
AWS NLB (L4 · UDP · sticky 5-tuple)
│
Target Group
┌────┼────┐
Pulse-A Pulse-B Pulse-C ...
≤4095 ≤4095 ≤4095 peers (`MaxPeers`, ENet-bounded)
Worker-shard isolation. Within a single instance, PeersManager shards peers across workers by PeerIndex.Value % workerCount. Each worker owns its own peerStates and observerViews and is the only thread that touches them. Cross-worker coordination goes through a single channel: the ENet thread writes MessagePipe.incomingChannel, PeersManager fans out to workerChannels[shard], each worker drains its own channel sequentially. No ad-hoc cross-worker state migration.
Peer lifecycle & slot recycling. PeerIndexAllocator runs a three-phase lifecycle: Allocate → MarkPending (on ENet disconnect) → Release (after per-peer board cleanup). The grace window is DisconnectionCleanTimeoutMs and must exceed SWEEP_INTERVAL × BaseTickMs so observers emit PlayerLeft before the slot is reusable. A mid-session wallet-mismatch check in PeerSimulation is the fallback when a slot aliases and an observer still holds the old wallet.
Fan-out ceiling: At dense events where all peers are within Tier 0, fan-out is O(N²) per tick. No shedding mechanism exists.
- Instruments:
PulseMetrics(counters / gauges viaSystem.Diagnostics.Metrics), zero-allocInterlockedupdates on the hot path. - Collector:
MeterListenerMetricsCollectorsubscribes and snapshots on demand. - Prometheus:
PrometheusFormatteremits text-exposition format;HttpServiceserves/metricswith bearer-token auth. - Console dashboard:
ConsoleDashboard(TUI on a dedicated thread) polls snapshots every 500 ms — rates, percentiles, sparklines, per-hardening counters.
- Runtime: .NET 10
- Language: C#
- Transport: ENet (native, via managed wrapper)
- Serialization: Standard protobuf +
protoc-gen-bitwisequantized accessors (Quantizehelpers) - Crypto: Local ECDSA validation (no network call)
- Infrastructure: AWS NLB (L4, UDP)
- SDK: Installed at
~/.dotnet(user-local)
Native dependency: ECDSA verification uses the Decentraland.RustEthereum NuGet package fetched from the decentraland/rust-ethereum GitHub Release. Version is pinned in src/Directory.Build.props (<RustEthereumVersion>). dotnet restore auto-populates the gitignored packages/ local feed via src/Directory.Build.targets, which invokes tools/fetch-rust-eth.{sh,ps1} when the matching nupkg is missing.
Build:
DOTNET_ROOT="$HOME/.dotnet" PATH="$HOME/.dotnet:$PATH" dotnet build src/DCLPulse/DCLPulse.sln -p:GenerateProto=falseTests:
DOTNET_ROOT="$HOME/.dotnet" PATH="$HOME/.dotnet:$PATH" dotnet test src/DCLPulse/DCLPulse.sln -p:GenerateProto=falseThe solution file is src/DCLPulse/DCLPulse.sln — always pass it explicitly (not in repo root). Use -p:GenerateProto=false unless explicitly regenerating proto files.
See DCLPulse.sln.DotSettings (Rider settings, checked in).
- Instance fields:
camelCase, no prefix —messagePipe,workerCount - Constants / static readonly:
UPPER_SNAKE_CASE—SECTION_NAME,RELIABLE - Types:
PascalCase—PeersManager,ENetChannel - Local variables / parameters:
camelCase - Primary constructors for DI when trivial; regular constructors when initialization is complex
- File-scoped namespaces:
namespace Pulse.X; varwhen type is clear from context
Tests: Use NSubstitute. Do not mention line numbers in comments.
- No movement lock during emotes. Client is responsible for not sending
Inputwhile emoting. Server relays emote events, not a movement authority. - No server-side scene simulation. Server relays and validates client-reported positions only. Cannot compute positions independently.
- Client drives resync. Server never proactively sends
PlayerStateFullwhen baseline goes stale — client detects the seq gap and sendsResync. - Unreliable channel for movement input. Avoids head-of-line blocking. A retransmitted stale position is worse than a dropped one.
state_flagsalways present inPlayerStateDelta. Boolean transitions (jump, land, fall) drive animation events; missing one is more expensive than the 2 bytes.server_tickunified clock. Millisecond wall time fromMonotonicTimeProvider, stamped by handlers on every snapshot. Used across all messages for animation scrubbing, dead reckoning, andEmoteCompleterexpiry checks. ENetpeer->roundTripTime / 2provides the one-way latency estimate. Not a simulation-tick counter.- Emote state inlined into
PeerSnapshot.EmoteState(emote ID, start tick, duration, stop reason) is a nullable column on the snapshot ring, not a separate board. One-shot expiry is finalized byEmoteCompleteron the owning worker's loop, which publishes aCompletedstop snapshot whennow - StartTick >= DurationMs; observers pick it up through the normal intermediate-snapshot scan. PeerIndexis not an identity. It's ENet's recycled slot ID (index into the host's peer table). Stable identity is the wallet address resolved during auth and held byIdentityBoard. Observer-side state keyed byPeerIndexmust be invalidated synchronously when the underlying peer disconnects, or it aliases the next peer that lands on that slot.
- Pulse address is hardcoded in the client. No dynamic assignment from Archipelago. Cannot route different islands to different Pulse instances at the application level; NLB handles it by sticky 5-tuple but Pulse has no awareness of island boundaries.
- Pulse and Hammurabi are blind to each other. Pulse knows avatar positions; Hammurabi owns scene entity state. No interface between them — position-based server-side logic (collision, triggers) is not enforceable.
- No graceful drain on deploy. All ENet connections drop simultaneously on instance restart.
- Client-reported positions are fully trusted. No server validation against scene geometry.