skills/authoritative-server/SKILL.md
Build multiplayer Decentraland scenes with a headless authoritative server. Covers isServer() branching, registerMessages() for client-server communication, validateBeforeChange() for server-only state, Storage (world and player persistence), EnvVar (environment variables), and project structure. Use when the user wants authoritative multiplayer, anti-cheat, server-side validation, persistent storage, or server messages. Do NOT use for basic CRDT multiplayer without a server (see multiplayer-sync).
npx skillsauth add dcl-regenesislabs/opendcl authoritative-serverInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build multiplayer Decentraland scenes where a headless server controls game state, validates changes, and prevents cheating. The same codebase runs on both server and client, with the server having full authority.
For basic CRDT multiplayer (no server), see the multiplayer-sync skill instead.
You must use the auth-server tag — the standard @dcl/sdk does NOT include authoritative server APIs (isServer, registerMessages, Storage, EnvVar, etc.):
npm install @dcl/sdk@auth-server
Your scene.json must include these properties:
authoritativeMultiplayer: true — enables the authoritative server runtime.worldConfiguration.name — identifies the world for deployment, Storage, and EnvVar.logsPermissions — array of wallet addresses allowed to see server logs in the console. Without this, server console.log() output is hidden.{
"authoritativeMultiplayer": true,
"worldConfiguration": {
"name": "my-world-name.dcl.eth"
},
"logsPermissions": ["0xYourWalletAddress"]
}
Just use the normal preview — it automatically starts the authoritative server in the background when authoritativeMultiplayer: true is set in scene.json.
Debugging note (do NOT tell the user to run this): Under the hood, the preview runs
npx @dcl/hammurabi-server@next. If the auth server isn't starting, check that the hammurabi process is running and look for errors in its output.
Use isServer() to branch logic in a single codebase:
import { isServer } from '@dcl/sdk/network'
export async function main() {
if (isServer()) {
// Server-only: game logic, validation, state management
const { server } = await import('./server/server')
server()
return
}
// Client-only: UI, input, message sending
setupClient()
setupUi()
}
The server runs your scene code headlessly (no rendering). It has access to all player positions via PlayerIdentityData and manages all authoritative game state.
Define custom components that sync from server to all clients. Always use validateBeforeChange() to prevent clients from modifying server-authoritative state.
import { engine, Schemas } from '@dcl/sdk/ecs'
import { AUTH_SERVER_PEER_ID } from '@dcl/sdk/network/message-bus-sync'
export const GameState = engine.defineComponent('game:State', {
phase: Schemas.String,
score: Schemas.Number,
timeRemaining: Schemas.Number
})
// Restrict ALL modifications to server only
GameState.validateBeforeChange((value) => {
return value.senderAddress === AUTH_SERVER_PEER_ID
})
For built-in components like Transform and GltfContainer, use per-entity validation so you don't block client-side transforms on the player's own entities:
import { Entity, Transform, GltfContainer } from '@dcl/sdk/ecs'
import { AUTH_SERVER_PEER_ID } from '@dcl/sdk/network/message-bus-sync'
type ComponentWithValidation = {
validateBeforeChange: (entity: Entity, cb: (value: { senderAddress: string }) => boolean) => void
}
function protectServerEntity(entity: Entity, components: ComponentWithValidation[]) {
for (const component of components) {
component.validateBeforeChange(entity, (value) => {
return value.senderAddress === AUTH_SERVER_PEER_ID
})
}
}
// Usage: after creating a server-managed entity
const entity = engine.addEntity()
Transform.create(entity, { position: Vector3.create(10, 5, 10) })
GltfContainer.create(entity, { src: 'assets/model.glb' })
protectServerEntity(entity, [Transform, GltfContainer])
After creating and protecting an entity, sync it to all clients:
import { syncEntity } from '@dcl/sdk/network'
syncEntity(entity, [Transform.componentId, GameState.componentId])
Use registerMessages() for client-to-server and server-to-client communication:
import { Schemas } from '@dcl/sdk/ecs'
import { registerMessages } from '@dcl/sdk/network'
export const Messages = {
// Client -> Server
playerJoin: Schemas.Map({ displayName: Schemas.String }),
playerAction: Schemas.Map({ actionType: Schemas.String, data: Schemas.Number }),
// Server -> Client
gameEvent: Schemas.Map({ eventType: Schemas.String, playerName: Schemas.String })
}
export const room = registerMessages(Messages)
// Client sends to server
room.send('playerJoin', { displayName: 'Alice' })
// Server sends to ALL clients
room.send('gameEvent', { eventType: 'ROUND_START', playerName: '' })
// Server sends to ONE client
room.send('gameEvent', { eventType: 'YOU_WIN', playerName: 'Alice' }, { to: [playerAddress] })
// Server receives from client
room.onMessage('playerJoin', (data, context) => {
if (!context) return
const playerAddress = context.from // Wallet address of sender
console.log(`[Server] Player joined: ${data.displayName} (${playerAddress})`)
})
// Client receives from server
room.onMessage('gameEvent', (data) => {
console.log(`Event: ${data.eventType}`)
})
Before sending messages from the client, wait until state is synchronized:
import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return
// Safe to send messages now
room.send('playerJoin', { displayName: 'Player' })
})
The server can read actual player positions — critical for anti-cheat:
import { engine, PlayerIdentityData, Transform } from '@dcl/sdk/ecs'
engine.addSystem(() => {
for (const [entity, identity] of engine.getEntitiesWith(PlayerIdentityData)) {
const transform = Transform.getOrNull(entity)
if (!transform) continue
const address = identity.address
const position = transform.position
// Use actual server-verified position, not client-reported data
}
})
Never trust client-reported positions. Always read PlayerIdentityData + Transform on the server.
Persist data across server restarts. Server-only — guard with isServer().
import { Storage } from '@dcl/sdk/server'
Shared across all players:
// Store
await Storage.world.set('leaderboard', JSON.stringify(leaderboardData))
// Retrieve
const data = await Storage.world.get<string>('leaderboard')
if (data) {
const leaderboard = JSON.parse(data)
}
// Delete
await Storage.world.delete('oldKey')
Keyed by player wallet address:
// Store
await Storage.player.set(playerAddress, 'highScore', String(score))
// Retrieve
const saved = await Storage.player.get<string>(playerAddress, 'highScore')
const highScore = saved ? parseInt(saved) : 0
// Delete
await Storage.player.delete(playerAddress, 'highScore')
Storage only accepts strings. Use JSON.stringify()/JSON.parse() for objects and String()/parseInt() for numbers.
Local development storage is at node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json.
Configure your scene without hardcoding values. Server-only — guard with isServer().
import { EnvVar } from '@dcl/sdk/server'
// Read a variable with default
const maxPlayers = parseInt((await EnvVar.get('MAX_PLAYERS')) || '4')
const debugMode = ((await EnvVar.get('DEBUG')) || 'false') === 'true'
Create a .env file in your project root:
MAX_PLAYERS=8
GAME_DURATION=300
DEBUG=true
Add .env to your .gitignore.
# Set a variable
npx sdk-commands deploy-env MAX_PLAYERS --value 8
# Delete a variable
npx sdk-commands deploy-env OLD_VAR --delete
Deployed env vars take precedence over .env file values.
src/
├── index.ts # Entry point — isServer() branching
├── client/
│ ├── setup.ts # Client initialization, message handlers
│ └── ui.tsx # React ECS UI reading synced state
├── server/
│ ├── server.ts # Server init, systems, message handlers
│ └── gameState.ts # Server state management class
└── shared/
├── schemas.ts # Synced component definitions + validateBeforeChange
└── messages.ts # Message definitions via registerMessages()
Put synced components and messages in shared/ so both server and client import the same definitions. Keep server logic (Storage, EnvVar, game systems) in server/. Keep UI and client input in client/.
[Server] and [Client] prefixes in console.log() to distinguish server and client output in the terminal.main.crdt and main1.crdt files and restart.node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json to inspect persisted data during local development.setTimeout/setInterval are available via runtime polyfill. For game logic, prefer engine.addSystem() with a delta-time accumulator to stay in sync with the frame loop.syncEntity(entity, [componentIds]) with the correct component IDs (MyComponent.componentId).Schemas.Int64 for timestamps: Schemas.Number corrupts large numbers (13+ digits). Always use Schemas.Int64 for values like Date.now().isStateSyncronized() (from @dcl/sdk/network) to return true before sending messages. Note the intentional SDK typo: "Syncronized" not "Synchronized".validateBeforeChange((value) => ...). Built-in components (Transform, GltfContainer) use per-entity validateBeforeChange(entity, (value) => ...).index.ts entry point. Use isServer() to branch.fs, http, etc. setTimeout/setInterval are supported. Use SDK-provided APIs (Storage, EnvVar, engine systems) for server-side operations.npm install @dcl/sdk@auth-server, not the standard @dcl/sdk. Without it, isServer(), registerMessages(), Storage, and EnvVar are unavailable.authoritativeMultiplayer: true must be set, and logsPermissions: ["0xWalletAddress"] must list wallet addresses that should see server logs.multiplayer-sync skill.For complete server setup examples, authentication flow, state reconciliation, Storage patterns, and EnvVar usage, see {baseDir}/references/server-patterns.md.
development
Capture screenshots of the running Decentraland preview to verify scene changes visually. Covers camera movement, interaction actions, and visual debugging. Use when the preview is running and you need to check what the scene looks like, debug visual issues, or verify layout. Do NOT use for code changes (make changes first, then screenshot).
development
Cross-cutting runtime APIs for Decentraland SDK7 scenes. Use when the user needs async operations (executeTask), HTTP requests (fetch, signedFetch), WebSocket connections, timers, realm/scene detection, restricted actions (movePlayerTo, teleportTo, triggerEmote, openExternalUrl), portable experiences, or the testing framework. Do NOT use for UI (see build-ui), multiplayer sync (see multiplayer-sync), or avatar/player data (see player-avatar).
development
Apply physics forces to the player in Decentraland scenes. Impulses (one-shot pushes), knockback (push away from a point with falloff), continuous forces (wind tunnels, anti-gravity, lift, levitation, hover), timed forces, and repulsion fields. Use when the user wants launch pads, knockback on hit, wind zones, gravity fields, jumps, lifting/floating the player, pushing the player up/sideways/back, hover effects, or any scene-applied force on the player. THIS is also the right skill when an agent's first instinct is to mutate `Transform` on `engine.PlayerEntity` to move/lift/push the player — that does NOT work (the player Transform is engine-controlled and read-only); use the Physics API instead. Do NOT use for player movement speed (see player-avatar AvatarLocomotionSettings) or platform movement (see animations-tweens).
data-ai
Player and avatar system in Decentraland. Read player position/profile, customize appearance (AvatarBase), trigger emotes (triggerEmote/triggerSceneEmote), read equipped wearables (AvatarEquippedData), attach objects to players (AvatarAttach), create NPC avatars (AvatarShape), avatar modifier areas, and locomotion settings. Use when the user wants player data, emotes, wearables, NPC avatars, avatar attachments, or movement speed changes. Do NOT use for wallet/blockchain interactions (see nft-blockchain).