-
Notifications
You must be signed in to change notification settings - Fork 27
Decentraland SDK7 Authoritative Server Guide
The authoritative server is your entire scene running as a headless simulation. It's not a separate server application - it's the same scene code you write, but running in the background without rendering graphics. This simulation becomes the single source of truth that all players synchronize with.
In multiplayer games, trust is a problem. Without an authoritative server:
- 🚫 Players can cheat by modifying their health, score, or position
- 🚫 Game state becomes inconsistent between players
- 🚫 No single source of truth for game rules
With an authoritative server:
- ✅ Server validates all actions
- ✅ Consistent game state for everyone
- ✅ Fair gameplay
Players (Clients) Authoritative Server
| |
|-- Send actions --------> |
| | (Validates & Updates State)
|<-- Receive updates ---- |
npm install @dcl/sdk@see-link-belowIn a terminal, run:
npx @dcl/hammurabi-serverThis starts your scene running in the background as a headless simulation. The server is not just a traditional server - it's actually your entire scene code running without rendering, acting as the authoritative source of truth.
Note: This connects to your scene on the default port 8000. If your scene runs on a different port, use --realm=localhost:PORT
In another terminal:
npm run startThink of a "room" as a communication channel between players and server.
IMPORTANT: All messages MUST be defined using Schemas (for binary serialization). You cannot send plain JSON objects.
import { registerMessages } from '@dcl/sdk/network'
import { Schemas } from '@dcl/sdk/ecs'
// ✅ CORRECT: All messages defined with Schemas
const Messages = {
playerShoot: Schemas.Map({
direction: Schemas.Vector3,
timestamp: Schemas.Int64
}),
hitConfirmed: Schemas.Map({
damage: Schemas.Int
}),
// Even empty messages need Schemas
ping: Schemas.Map({})
}
// ❌ WRONG: Plain objects don't work
const BadMessages = {
playerShoot: { direction: "Vector3" }, // This will fail!
hitConfirmed: { damage: "number" } // This will fail!
}
// Create the room
const room = registerMessages(Messages)// Basic types
Schemas.String // "hello"
Schemas.Int // 42
Schemas.Float // 3.14
Schemas.Bool // true/false
Schemas.Int64 // Date.now()
// Vector types
Schemas.Vector3 // { x: 1, y: 2, z: 3 }
Schemas.Quaternion // { x, y, z, w }
// Complex types
Schemas.Array(Schemas.String) // ["a", "b", "c"]
Schemas.Entity // Entity reference
// Optional/nullable properties
Schemas.Optional(Schemas.String) // "hello" or undefined
Schemas.Optional(Schemas.Int) // 42 or undefined
// Nested objects
Schemas.Map({
name: Schemas.String,
health: Schemas.Int,
playerId: Schemas.Optional(Schemas.String), // Can be undefined
position: Schemas.Vector3
})Use isServer() to separate logic:
import { isServer } from '@dcl/sdk/network'
export function main() {
if (isServer()) {
// This code ONLY runs on the server
console.log("I'm the server!")
} else {
// This code ONLY runs on players' clients
console.log("I'm a player!")
}
}Client → Server:
// Player shoots
room.send('playerShoot', {
direction: { x: 0, y: 0, z: 1 }
})Server → Client:
if (isServer()) {
room.onMessage('playerShoot', (data, context) => {
// context.from tells you WHO sent this
console.log(`Player ${context.from} shot towards`, data.direction)
// Server decides if hit happened
if (checkHit(data.direction)) {
// Send back to that specific player
room.send('hitConfirmed',
{ damage: 10 },
{ to: [context.from] }
)
}
})
}Some data should ONLY be modified by the server. Here's how:
import { engine, Schemas } from '@dcl/sdk/ecs'
const GameScore = engine.defineComponent('GameScore', {
playerA: Schemas.Int,
playerB: Schemas.Int
})import { AUTH_SERVER_PEER_ID } from '@dcl/sdk/network/message-bus-sync'
// This prevents clients from modifying the score
GameScore.validateBeforeChange((value) => {
// Only accept changes from server
return value.senderAddress === AUTH_SERVER_PEER_ID
})import { syncEntity } from '@dcl/sdk/network'
if (isServer()) {
// Create entity
const scoreEntity = engine.addEntity()
// Mark it for synchronization
syncEntity(scoreEntity, [GameScore])
// Set initial values
GameScore.create(scoreEntity, {
playerA: 0,
playerB: 0
})
// Modify when someone scores
room.onMessage('playerScored', (data, context) => {
const score = GameScore.getMutable(scoreEntity)
score.playerA += 1
// This change automatically syncs to all clients!
})
}if (!isServer()) {
// Wait for the score entity to exist
engine.addSystem(() => {
const result = engine.getEntitiesWith(GameScore).next()
if (result.value) {
const [scoreEntity] = result.value
// Listen for changes
GameScore.onChange(scoreEntity, (score) => {
updateScoreUI(score.playerA, score.playerB)
})
// Remove this system once setup is done
engine.removeSystem('setup')
}
}, 0, 'setup')
}Here's a simple multiplayer counter:
import { engine, Schemas } from '@dcl/sdk/ecs'
import { registerMessages, isServer, syncEntity } from '@dcl/sdk/network'
import { AUTH_SERVER_PEER_ID } from '@dcl/sdk/network/message-bus-sync'
// 1. Define messages
const Messages = {
increment: Schemas.Map({}), // Empty message
stateUpdate: Schemas.Map({
count: Schemas.Int,
lastPlayer: Schemas.String
})
}
// 2. Define server-only component
const Counter = engine.defineComponent('Counter', {
value: Schemas.Int,
lastPlayer: Schemas.String
})
// 3. Add validation
Counter.validateBeforeChange((value) => {
return value.senderAddress === AUTH_SERVER_PEER_ID
})
// 4. Create room
const room = registerMessages(Messages)
export function main() {
if (isServer()) {
// === SERVER CODE ===
const counterEntity = engine.addEntity()
syncEntity(counterEntity, [Counter])
Counter.create(counterEntity, {
value: 0,
lastPlayer: 'none'
})
room.onMessage('increment', (data, context) => {
// Server validates and updates
const counter = Counter.getMutable(counterEntity)
counter.value += 1
counter.lastPlayer = context.from
// Broadcast update
room.send('stateUpdate', {
count: counter.value,
lastPlayer: context.from
})
})
} else {
// === CLIENT CODE ===
// Add button to increment
const button = engine.addEntity()
// ... add transform, mesh, etc.
pointerEventsSystem.onPointerDown(button, () => {
room.send('increment', {})
})
// Listen for updates
room.onMessage('stateUpdate', (data) => {
console.log(`Count: ${data.count} (by ${data.lastPlayer})`)
})
}
}-
Start server:
npx @dcl/hammurabi-server - Make changes to your scene code
- Save - Server auto-reloads
- Test in preview
- Check logs - Server logs appear in terminal
ps aux | grep hammurabiif (isServer()) {
console.log('[SERVER] Starting...')
room.onMessage('test', () => {
console.log('[SERVER] Received test message')
})
} else {
console.log('[CLIENT] Starting...')
}// On server
console.log('Creating entity:', entityId)
// On client
engine.addSystem(() => {
const entities = Array.from(engine.getEntitiesWith(MyComponent))
console.log('Synced entities:', entities.length)
})// BAD - Clients can modify!
const Score = engine.defineComponent('Score', schema)// GOOD - Server-only modifications
Score.validateBeforeChange((value) => {
return value.senderAddress === AUTH_SERVER_PEER_ID
})// BAD
room.onMessage('setHealth', (data) => {
player.health = data.health // Client controls health!
})// GOOD
room.onMessage('takeDamage', (data) => {
const damage = calculateDamage(data.source)
player.health = Math.max(0, player.health - damage)
})// BAD - Sending every frame
engine.addSystem(() => {
room.send('position', transform.position)
})// GOOD - Send periodically
let lastSend = 0
engine.addSystem((dt) => {
lastSend += dt
if (lastSend > 0.1) { // Every 100ms
room.send('position', transform.position)
lastSend = 0
}
})| Colyseus | SDK7 Authoritative |
|---|---|
room.send() |
room.send() ✅ Same! |
room.onMessage() |
room.onMessage() ✅ Same! |
room.state.players |
Use syncEntity + components |
| JSON serialization | Binary (automatic) |
| Custom server | npx @dcl/hammurabi-server |
Unlike Colyseus which sends only diffs, every component change sends the entire component data. Design components carefully for network efficiency.
// BAD - Changing score sends everything
const GameState = engine.defineComponent('GameState', {
playerAScore: Schemas.Int,
playerBScore: Schemas.Int,
timer: Schemas.Int,
playerPositions: Schemas.Array(Schemas.Vector3) // Huge array!
})// GOOD - Only sends what changed
const PlayerScore = engine.defineComponent('PlayerScore', {
playerA: Schemas.Int,
playerB: Schemas.Int
})
const GameTimer = engine.defineComponent('GameTimer', {
secondsLeft: Schemas.Int
})
// Score change = 8 bytes, Timer change = 4 bytesRemember: Never trust the client. Always validate on the server.
To properly test authoritative servers, you have two options:
- Download specific build: Use the pre-built client from https://github.com/decentraland/unity-explorer/pull/4857
- Run Unity locally: Clone that branch and run Unity locally
When running npx @dcl/hammurabi-server, specify your scene's port:
# If scene runs on port 8080
npx @dcl/hammurabi-server --realm=localhost:8080
# Default is localhost:8000 (no param needed if scene runs on 8000)
npx @dcl/hammurabi-servernpx @dcl/hammurabi-server currently fails due to gatekeeper authentication issues.
Temporary solutions:
- Wait for fix: https://github.com/decentraland/comms-gatekeeper/pull/107
- Run gatekeeper locally: Clone the PR branch above and contact the team for environment variables