.NET-based authoritative server for fulminant social interactions
Protocol C# files are auto-generated from the sibling protocol repo using protoc + the protoc-gen-bitwise plugin.
The protocol repo must be checked out as a sibling of this repo:
D:\<root>\protocol\ ← @dcl/protocol (quantization branch)
D:\<root>\Pulse\ ← this repo
You can override the path via Directory.Build.props or -p:_ProtocolRepo=<path>.
The Protocol.csproj has a GenerateProto property that controls whether .proto files are regenerated at build time or the committed Generated/ files are used as-is.
| Mode | When to use | What happens |
|---|---|---|
GenerateProto=true (default) |
Local development with the protocol repo available |
Runs protoc + bitwise plugin, regenerates src/Protocol/Generated/ |
GenerateProto=false |
Docker builds, CI, or when the protocol repo is not available |
Skips generation, compiles committed Generated/*.cs files directly |
To build without generation:
dotnet build -p:GenerateProto=falseAfter modifying .proto files, build normally (or explicitly with GenerateProto=true) and commit the updated Generated/ files so Docker and CI builds stay in sync.
To set GenerateProto from Rider:
- Solution-wide: Settings → Build, Execution, Deployment → Toolset and Build → MSBuild CLI arguments → add
-p:GenerateProto=false - Per configuration: Run → Edit Configurations → select configuration → Before launch → click the build step → add
-p:GenerateProto=falseto MSBuild arguments
In practice the default (true) is correct for local development. The false value is used by Docker/CI builds that don't have the protocol repo available.
ECDSA signature verification on the auth chain is delegated to the
decentraland/rust-ethereum NuGet
package. The package is not on a public feed — it ships as a GitHub Release
asset and is fetched from there on demand.
The version is the single source of truth in src/Directory.Build.props:
<RustEthereumVersion>0.2.0-rc2</RustEthereumVersion>Both consuming projects (DCLAuth, Fuzzer) reference it via
$(RustEthereumVersion). To bump, edit that one value.
src/NuGet.config adds the repo-root packages/ directory as a local NuGet
source. The directory is gitignored — populated automatically on every
dotnet restore via src/Directory.Build.targets, which runs
tools/fetch-rust-eth.{sh,ps1} before Restore if the matching
Decentraland.RustEthereum.<version>.nupkg is missing.
Bump RustEthereumVersion and the next restore pulls the new nupkg from the
GitHub Release. The script is idempotent and concurrency-safe (temp file +
atomic rename), so the parallel restore graph triggering it from every project
is fine.
To pre-fetch outside of a restore (e.g. air-gapped builds), invoke the script directly:
bash tools/fetch-rust-eth.sh # Linux / macOS / git-bash
pwsh tools/fetch-rust-eth.ps1 # Windows PowerShellPulse uses ENet over UDP. A couple of non-obvious behaviors worth knowing before reading or changing server code:
PeerIndexis a recycled slot, not an identity. It wrapsENetPeer.ID, which ENet reassigns to the next connecting peer as soon as the previous one is freed. The stable identity is the wallet address from the auth handshake — resolve it throughIdentityBoardrather than treatingPeerIndexas the player.- Any per-observer state keyed by
PeerIndexmust be invalidated on disconnect. If a view, cache, or baseline keyed byPeerIndexoutlives the peer, the next peer that lands on that slot will silently inherit the stale state — noPlayerJoinedfor the new player, wrong wallet cached on clients, deltas diffed against the old baseline. - Channel convention is enforced by packet flags, not by ENet. Ch0 is reliable control flow (snapshots, events, resyncs); ch1 is unreliable sequenced (high-frequency state updates, input).
Two knobs control concurrent-peer capacity:
Transport.MaxPeers— size of thePeerIndexpool and every per-peer array board (SnapshotBoard,IdentityBoard,ProfileBoard,SpatialGrid). Hard ceiling on active + in-grace slots.Transport.MaxConcurrentConnections— ENet host capacity.0=MaxPeers. Set belowMaxPeersto reserve slots for the allocator's pending-recycle grace window — without headroom, a burst of reconnects can exhaust thePeerIndexpool while ENet still has free slots, causingSERVER_FULLrefusals on otherwise admittable connections.
Rule of thumb: MaxConcurrentConnections ≈ MaxPeers - ceil(peakDisconnectsPerSecond × Peers.DisconnectionCleanTimeoutMs / 1000).
Pulse includes a real-time terminal dashboard for monitoring transport throughput, queue backpressure, and per-message-type rates during development. Enable it with "Dashboard": { "Enabled": true } in appsettings.json.
See docs/metrics.md for a full reference of all tracked metrics, how to interpret them, and how to add new ones.
A headless test client that connects to the Pulse server as a bot player. Uses ENet over UDP with the same protocol as the Unity client. Supports running multiple bots from a single process for load testing. Useful for load testing, debugging server behavior, and verifying the protocol without launching the full Explorer.
- MetaForge CLI installed and available on
PATH(the client shells out tometaforgefor account creation, auth chain signing, and profile fetching) - A running Pulse game server
dotnet run --project src/DCLPulseTestClient| Argument | Default | Description |
|---|---|---|
--account=<name> |
enetclient-test |
MetaForge account name (or prefix when using multiple bots) |
--bot-count=<N> |
1 |
Number of bots to spawn in the same process |
--ip=<address> |
127.0.0.1 |
Server IP address |
--port=<port> |
7777 |
Server UDP port |
--pos-x=<float> |
-104 |
Spawn position X (Genesis Plaza) |
--pos-y=<float> |
0 |
Spawn position Y |
--pos-z=<float> |
5 |
Spawn position Z |
--spawn-radius=<float> |
10 |
Radius of the circle bots spawn on around the initial position |
--dispersion-radius=<float> |
20 |
Max distance a bot can wander from the spawn origin |
--rotate-speed=<deg/s> |
90 |
Idle rotation speed in degrees per second |
Example — single bot connecting to a remote server:
dotnet run --project src/DCLPulseTestClient -- --account=bot1 --ip=10.0.0.5 --pos-x=0 --pos-z=0Example — 10 bots for load testing:
dotnet run --project src/DCLPulseTestClient -- --account=loadtest --bot-count=10 --ip=10.0.0.5When --bot-count=1, the account name is used as-is. When --bot-count > 1, accounts are named <account>-0, <account>-1, ..., <account>-N-1 and bots spawn in a circle around the initial position.
On startup each bot authenticates via MetaForge, connects over ENet, completes the handshake, announces its profile, then enters a 30 fps simulation loop.
Autonomous behavior (default):
- Wanders using three Perlin noise generators (forward, strafe, rotation) producing smooth, organic movement at 5 units/s
- Plays a random emote from the profile's emote list every 5 seconds, then stands still for a 5-second cooldown before resuming movement
- Handles resync — detects sequence gaps in incoming state deltas and sends
RESYNC_REQUESTto the server - Sends
PlayerStateInputon the unreliable sequenced channel every tick with position, velocity, rotation, and state flags (Grounded)
Keyboard override (single-bot mode only) — the bot accepts keyboard input in parallel:
| Key | Action |
|---|---|
| W / A / S / D | Move forward / left / backward / right |
| Q / E | Rotate left / right |
| B then 0–9 | Play emote by index |
| ESC | Quit |
In multi-bot mode keyboard input is disabled; use Ctrl+C to stop all bots.
All bots share a single ENet Host (one UDP socket, one service thread) with N peers. Each bot gets its own MessagePipe and PulseMultiplayerService for isolated message routing. The ENet transport multiplexes outgoing messages from all bots and routes incoming packets by peer ID.
ENetTransport (shared) One Host, one thread, N peers
├── BotSession 0 Per-bot state + pipe + service
│ ├── BotTransport ITransport adapter → shared ENetTransport
│ ├── MessagePipe Isolated incoming/outgoing channels
│ ├── PulseMultiplayerService Handshake, subscriptions
│ └── Bot Perlin-noise input generator
├── BotSession 1
│ └── ...
└── BotSession N-1
└── ...
Program.cs Entry point, N-bot orchestration, shared game loop
BotSession.cs Per-bot state (position, rotation, seq tracking)
├── Auth/
│ ├── MetaForgeAuthenticator Shells out to `metaforge account create/chain`
│ └── IAuthenticator Interface for swappable auth strategies
├── Profiles/
│ ├── MetaForgeProfileGateway Shells out to `metaforge account info`
│ └── IProfileGateway Interface for swappable profile sources
├── Inputs/
│ ├── Bot Perlin-noise wandering + periodic emotes
│ ├── ConsoleInputReader WASD + emote keyboard input (single-bot only)
│ ├── BotWithManualExitInput Composite: keyboard (ESC check) → Bot
│ └── PlayLoopEmote One-shot: fires a single looping emote
├── Networking/
│ ├── ENetTransport Shared ENet Host, multi-peer, single thread
│ ├── BotTransport Per-bot ITransport adapter
│ ├── MessagePipe Thread-safe channel bridging transport ↔ game thread
│ └── PulseMultiplayerService Handshake, message routing, typed subscriptions
└── ParcelEncoder Global ↔ parcel-relative position conversion
Local debugging uses docker-compose.debug.yml with Rider attaching via the Docker socket. Remote debugging against the dev environment uses Dockerfile.dev-debug (Debug build + vsdbg + sshd + pre-installed JetBrains RiderRemoteDebugger) deployed via the Deploy Dev (Debug) GitHub Action, with Rider attaching over SSH through the bastion.
See docs/debugging.md for full setup and workflows. Bastion/tunnel specifics are in the decentraland/playbooks repo (internal access only).
The HTTP service listens on port 5000 by default and the port must be free for the server to start. On macOS, port 5000 is grabbed by AirPlay Receiver out of the box — disable it under System Settings → General → AirDrop & Handoff → AirPlay Receiver, or override the port via HttpService:Port in appsettings.json.