Authoritative Servers
Build multiplayer Decentraland scenes with a headless authoritative server.
Overview
Decentraland runs scenes locally in a player's machine. By default, players are able to see each other and interact directly, but each one interacts with the environment independently. Changes in the environment aren't shared between players by default.
Allowing all players to see a scene as having the same content in the same state is extremely important to for players to interact in more meaningful ways. Without this, if a player opens a door and walks into a house, other players will see that door as still closed, and the first player will appear to walk directly through the closed door to other players.
An authoritative server is a headless server process that runs your scene code, validates state changes, and broadcasts the result to all connected players. Instead of trusting each client to report its own actions, the server acts as the single source of truth. This makes it the recommended approach for syncing multiplayer scenes.
An authoritative server is ideal whenever fairness is important for game mechanics, as you can implement elaborate anti-cheat validations that run server-side. You can also store private keys and other sensitive information on the server, avoiding ever needing to expose them directly to the user.
Having an authoritative server also solves a real problem: in a peer-to-peer setup, two players controlling something like a floating platform can produce conflicting outcomes. Each client sets the platform to a different height, and no one has the authority to decide which is correct. An authoritative server resolves every change in one place, so all clients converge on the same state.
It also gives you a place to persist data across sessions: leaderboards, player progression, unlocked achievements, or environment changes like doors opened or items placed. When players come back, the world reflects what happened before.
Decentraland hosts and deploys the server for you. Publishing your scene via the normal process also seamlessly publishes the server, with no extra steps or needing to pay for any hosting.
Setup
1. Install the auth-server SDK version
The native authoritative server APIs (isServer, registerMessages, Storage, EnvVar, etc.) are available on a separate SDK branch. Run the following commands to install it in your project instead of the standard SDK branch:
npm install @dcl/sdk@auth-server
npm install @dcl/js-runtime@auth-server2. Configure scene.json
Optionally add the following to your scene.json at root level:
Add logsPermissions to list wallet addresses that can see console.log() from the server. The listed users can then view server logs in production by running the following command:
npx sdk-commands sdk-server-logs
3. Run the preview
Use the standard preview command, no extra steps needed. When using the auth-server branch of the SDK, the preview automatically starts a local version of the authoritative server in the background.
The local session of the server is not connected to the one in production, so you're free to test things without affecting players who are in your published scene.
Server / Client Branching
The scene code in your project's src folder runs on both server and client. Use the isServer() function to split execution paths:
The server runs your scene headlessly with no rendering. It has verified access to all player positions, wearables and other data via PlayerIdentityData and is the sole authority over game state.
Synced Components and Validation
Syncing Entities to All Clients
Use syncEntity to broadcast any changes in the indicated components of that entity:
The syntax is identical to what's used by the Serverless multiplayer feature, making it trivial to upgrade a scene from using this architecture to the authoritative server. When a scene uses the authoritative server, state updates are no longer sent between all players, instead all state updates are now routed and validated via the server.
📔 Note: With the authoritative server, the ideal pattern is to only have the server call syncEntity. That way you don't need to worry about entity-id consistency. Rather, the entity is instanced and shared by the server, and all clients get synced about that instance. Always guard it with isServer(). This is different from Serverless multiplayer, where every client calls syncEntity on its own.
Validating changes
Use validateBeforeChange() to restrict any state updates in a specific component of an entity. It allows you to run a custom validation function, and changes are only successful when the validation test is met.
If the validation returns the value true, then the change is accepted and propagated to all players. If the validation returns the value false, then the change is rejected. A rejected change won't be passed to other players and is reverted for the player who attempted to make it.
Validate values
The simplest case is to validate that the new value being written is within certain parameters. For example, only accept changes to a Transform when the new Y position is above 0:
Because validateBeforeChange() only has meaning on the server, always guard it with isServer(). On the client the call does nothing useful.
You can use this to prevent changes that are against your game logic, as anti cheat mechanisms.
Validate proximity to the player
You can combine validateBeforeChange() with server-verified player positions to check that a player is close enough to an object before allowing them to interact with it. For example, when a player tries to pick up an object by changing its Transform, you can reject the change if the object is more than 5 meters away from the player:
This pattern is useful as an anti-cheat mechanism: it prevents players from reaching across the scene to grab objects they shouldn't be able to interact with.
Only allow changes by admins
You can also validate based on who is sending the change. Every incoming value includes a senderAddress field with the wallet address of the sender. Use this to only allow changes from certain players. For example, to only allow scene admins to modify a VideoPlayer component:
See Scene Admin for more context about how players become admins on a scene.
Only allow changes by the server
The strictest case is to only accept writes that originate from the server itself, rejecting any change coming from a client. This is the go-to pattern for state that should be fully authoritative: scores, game phase, spawned entities, etc.
Every incoming value includes a senderAddress field. When the sender is the server, this field matches the constant AUTH_SERVER_PEER_ID, exported from @dcl/sdk/network/message-bus-sync.
The example below defines a small protectServerEntity() helper that applies this check to one or more components on a given entity. It's a convenient way to protect multiple components (like Transform and GltfContainer) in a single call:
📔 Note: Always call protectServerEntity() inside an isServer() block. It wraps validateBeforeChange(), which only has meaning on the server — calling it on a client produces errors.
Custom Components
You can also apply validateBeforeChange() on custom components defined by the scene.
Messages
Synced components are great for state that all players should see continuously: things like positions, scores, or game phase. But not everything fits that model. Sometimes a player just needs to tell the server "I clicked this button" or "I want to join the game", and the server needs to reply with a one-time response like "round started" or "here are your stats". These are events, not ongoing state.
That's what messages are for. Use registerMessages() for typed, schema-validated communication between clients and the server. Messages are fire-and-forget: a client sends one to the server, the server processes it and optionally sends one back. They don't directly create any persistent state on their own.
Define Messages
Define all messages in a shared file that both server and client import. This way both sides always agree on what messages exist and what data they carry. Each message is a Schemas.Map describing its payload:
Send Messages
Clients can only send messages to the server. There is no direct client-to-client messaging. The server can broadcast to all clients or target specific players by address.
Receive Messages
On the server, every received message includes a context object with the sender's verified wallet address. Use this to know which player sent the message (never rely on self-reported identity in the payload).
Wait for State Sync Before Sending
Clients should wait until the scene state is synced before sending their first message, to avoid race conditions on join:
Available Schema Types
All message payloads and custom components use Schemas for binary serialization. Here is a quick reference of the types available:
📔 Note: Messages must be defined using Schemas.Map(...). You cannot send plain JavaScript objects, they will fail binary serialization.
Server Reading Player Positions
The server can read verified player positions; clients cannot spoof these. This is the foundation of position-based anti-cheat:
📔 Note: Always use PlayerIdentityData + Transform on the server to get player positions. Never trust values reported by the client itself.
Data Storage
Persist data across server restarts. Storage is server-only, always guard calls with isServer(). The server can both write and read this data.
Data can be stored at two levels:
World: Use this for data that is relevant to all players, like leaderboards or persistent environment changes.
Player: Use this for player-specific data, like saving progress or preferences for that player.
💡 Tip: Storage only accepts strings. Use JSON.stringify() / JSON.parse() for objects and String() / parseInt() for numbers.
During local development, storage is written to node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json.
World Storage — Shared Across All Players
You can also manage scene storage via the command line, using npx sdk-commands storage scene:
Player Storage — Per Wallet Address
You can also manage player storage via the command line, using npx sdk-commands storage player:
Access stored data
You can see and edit the live stored data on your server via the storage UI, by entering this link:
You can also reach this page via the Creator Hub. Open the Manage tab, click the three dots next to a place where you have published content, and select View server data.
There you can see a list of all the worlds and land where you can publish scenes.
Open your scene and then the Scene or Player tab.
In the Scene tab you'll see a list of all the stored variables. From here you can edit or remove any of these variables by clicking the pencil or trash icon.

In the Player tab you'll see a list of all the players who have any data stored on your server. You can search them by address or name, and then see all their associated data. You can also edit or remove this data by clicking the pencil or trash icon.
Changing the data structure
Stored data in production is not cleared when you publish a new version of your scene. This is great for leaderboards, player progress, and persistent environment changes that players expect to live on beyond any small updates to your scene.
The flip side is that the data sitting in storage was written by an older version of your code. If your new code expects a different shape, parsing or reading that old data can fail in subtle ways. A field you renamed will be missing. A field that used to be a string and is now an object will throw when you try to access a property on it. A player who hasn't logged in for months may load data that predates a structure your code no longer knows how to handle.
📔 Note: Schema changes don't just affect the very first read after a deploy. Stored data lives until it's overwritten or deleted, so an old-format value can surface at any time, often from a returning player you'd forgotten about.
Best practices
Always parse defensively. Treat anything coming out of storage as untrusted input, even though you wrote it. Wrap
JSON.parse()in atry/catch, check that fields exist before reading them, and have a sensible default ready when they don't:Add fields, don't rename or remove them. The safest schema change is an additive one: introduce a new field with a default, and leave existing fields alone. Old data will simply be missing the new field, which your defensive parsing already handles. Renaming a field forces every old record to break.
Version your stored objects. Include a
versionfield from day one. When you read data, branch on the version and migrate older shapes into the current one before using them. This keeps the rest of your code working with a single, current shape:Write back the migrated value. Once you've upgraded a record in memory, save it back so the next read is already in the new format. Over time this drains the pool of old-shape records without needing a one-shot migration script.
For breaking changes, use a new key. If the new structure is genuinely incompatible and migrating isn't worth it, write to a new storage key (for example
progress_v2) and ignore the old one. The old key sits harmlessly in storage and you avoid any read path that has to interpret it. You can clean up the old keys later via the storage UI or thenpx sdk-commands storagecommands.Test against real production data. Before deploying a structural change, pull a few real records from the storage UI and run your new parsing code against them. The corner cases that cause problems are usually records you didn't know existed.
Leave an escape hatch. Keep in mind that you can edit or delete individual records from the storage UI or via
npx sdk-commands storage. If a single player ends up wedged in a bad state after a schema change, you can fix their record directly without redeploying.
Environment Variables
Configure your scene without hardcoding values into the code. Environment variables are useful for sensitive data, and also for feature flags or parameters that can be easily changed without republishing your scene.
Environment variables are server-only. Guard them with isServer(). The server can read environment variables, but not change their values.
EnvVar.get() returns a Promise<string> and resolves to an empty string when the variable isn't set, so always provide a fallback for missing values:
Sensitive data
Environment variables are especially useful for storing private keys, reward claim codes, and other sensitive data that would be risky to expose in the public scene's compiled code.
You can store private keys in the server's storage, and have only the server read these with isServer(). That way the sensitive data never travels through the player's machine.
Local Development
To use environment variables while running your project locally, create a .env file in your project root:
Important: Add .env to your .gitignore, so that these potentially sensitive values are never uploaded to the public content servers.
Change environment variables
The easiest way to change the values of your environment variables is via the storage UI.
You can access the data that is stored by the scene's storage by entering this link:
You can also reach this page via the Creator Hub. Open the Manage tab, click the three dots next to a place where you have published content, and select View server data.
There you can see a list of all the worlds and land where you can publish scenes.
Open your scene and then the Environment tab. You should see all of the environment variables in the project.

Note that you cannot read the values of any of these environment variables (that's to protect sensitive data) but you can delete or overwrite any of them. Simply click the pencil or trash-can icon.
You can also manage environment variables via the command line, using npx sdk-commands storage env:
You can also target a specific environment with the --target flag:
Deployed environment variables take precedence over .env values.
Recommended Project Structure
Separating server, client, and shared code keeps the codebase readable as it grows:
💡 Tip: Keep all registerMessages() calls and custom component definitions in shared/. Both server and client import from there, ensuring they always agree on message schemas.
Performance Best Practices
Every component change sends the entire component data over the network. This is different from what Colyseus does, which sends only diffs. When designing custom components, keep this in mind. The optimal solution may be to store data in separate components, based on change frequency.
❌ Avoid monolithic components
✅ Prefer atomic components
Rule of thumb: group fields that change together and at a similar frequency. Separate fast-changing data (timers, positions) from slow-changing data (scores, configuration).
Throttle frequent messages
Avoid sending messages on every frame. Batch or throttle where possible:
For example if the server controls a countdown timer, it's not necessary to send updates to all players every second. It's best to have each client calculate passage of time on their own, and have the server broadcast its current state every 30 seconds or so, to ensure consistency.
Common Pitfalls
Forgetting validation on server-only state
Without validateBeforeChange, clients can write to any component:
Trusting client-supplied values
Never let a client dictate its own values for important data like health, score, or position:
Sending messages before state sync
Clients must wait until state is synchronized before interacting:
Wait for the server to start up
The server is only active if there's at least one player present in the scene. If nobody's currently there, the server shuts down after a few minutes.
When a first player comes into the scene after a while of inactivity, the server takes a few seconds to start up. Your scene's code should be prepared to have to wait for the server to be online. Initial requests to the server should have catch and retry mechanisms to provide resilience.
Complete Example
A minimal multiplayer counter: click a button, the server increments a synced score. The server persists the counter to Storage so the value survives server restarts. Remember that the server shuts down when no players are in the scene, so without storage the count would reset to zero every time the scene is left empty of players.
Testing Locally
The standard preview handles everything. When using the auth-server branch of the SDK, the local server starts automatically in the background alongside the client preview.
To test multiplayer interactions locally, open the preview in two separate windows, each window is treated as a separate player. Connect each window with a different address. Both clients will connect to the same local server instance.
Using the Creator Hub, click the Preview button a second time, and that opens a second Decentraland explorer window. You must connect on both windows with different addresses. The same sessions will remain open as the scene reloads.
As an alternative, you can open a second Decentraland explorer window by writing the following into a browser URL:
decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true&multi-instance=true
Debugging tips
Prefix your logs with
[SERVER]or[CLIENT]so you can tell them apart in the terminal:Verify component sync on the client by logging entity counts:
Debug in Production
To see console.log() output from your published server, your wallet address must be listed in the logsPermissions array in scene.json:
Without this, server logs are hidden in production, even from the scene owner.
Stream live server logs from the command line by running this in your project folder
You can also manually specify the world name the logs with:
You'll be prompted to sign a message with one of the wallets listed in logsPermissions to authenticate. Once connected, you'll see server-side console.log() output in real time, which is useful for diagnosing issues without needing to redeploy.
View storage data
You can access the data that is stored by the scene's storage by entering this link:
You can also reach this page via the Creator Hub. Open the Manage tab, click the three dots next to a place where you have published content, and select View server data.
There you can see a list of all the worlds and land where you can publish scenes.
Open the world or the player data to see the info that's stored for each.
For example if a particular player has an issue when playing your scene, you could look up this player via address, and see what data is stored for them to understand their situation. Maybe they stumbled upon a corner case where they ended up with contradicting data. You can even clear or edit that player's data from this page, to restore them into a stable state.
Version Control
Every published version of your scene gets its own unique hash ID, and each hash is paired with its own server instance. This means that the client code and the server code always move together, there is no window where a client running old logic talks to a server running new logic (or vice versa).
When you publish an update:
Players already in the scene keep seeing the old version of the scene until they leave and come back. Their clients stay connected to the server instance that matches the old hash.
New players arriving after the update load the new scene version and connect to the new server instance.
This guarantees that client and server state never fall out of sync because of a schema change or a renamed component. An update can never break the session of a player who is already in your scene.
The trade-off is that, for a short window right after a deploy, players can end up split across two different server instances. A player who was already there and a player who just arrived may not see each other or be able to interact via the scene, even though they are in the same scene, until the older players leave and rejoin.
💡 Tip: Data stored via the Storage service (like leaderboards, player progress, or persistent environment changes) is not wiped between versions. Storage is persisted at the location level and shared across all server instances that point to the same scene, so new versions pick up right where the previous one left off.
Migrating from Colyseus
If you have an existing scene built on Colyseus, the table below maps common Colyseus patterns to their SDK7 equivalents:
room.send(type, data)
room.send(type, data) — same API
room.onMessage(type, cb)
room.onMessage(type, cb) — same API
room.state.players (schema)
syncEntity + custom components
JSON serialization
Binary serialization (automatic via Schemas)
Separate server application
Same codebase — isServer() branching
Custom server hosting
Built-in: preview runs the server automatically
Key differences to keep in mind:
Serialization: Colyseus sends JSON diffs; the SDK sends the full component on every change. Keep components small (see Performance Best Practices).
State model: Colyseus uses a mutable state tree with automatic diffing. The SDK uses ECS components synced via
syncEntityand protected withvalidateBeforeChange.Hosting: No separate server deployment. The authoritative server is deployed automatically together with the scene.
Last updated