Skip to content

Decentraland SDK7 Authoritative Server Guide

Gon Pombo edited this page Sep 2, 2025 · 7 revisions

What is the Authoritative Server?

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.

Why Authoritative Servers?

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

How It Works

Players (Clients)          Authoritative Server
     |                            |
     |-- Send actions -------->   |
     |                            | (Validates & Updates State)
     |<-- Receive updates ----    |

Quick Start

Step 1: Install SDK

npm install @dcl/sdk@see-link-below

@dcl/sdk@authorative-link

Step 2: Start the Server

In a terminal, run:

npx @dcl/hammurabi-server

This 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

Step 3: Run Your Scene

In another terminal:

npm run start

Core Concepts

1. The Room - Message System

Think 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)

Available Schema Types

// 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
})

2. Client vs Server Code

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!")
  }
}

3. Sending and Receiving Messages

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] }
      )
    }
  })
}

Server-Authoritative Components

Some data should ONLY be modified by the server. Here's how:

Step 1: Define Component

import { engine, Schemas } from '@dcl/sdk/ecs'

const GameScore = engine.defineComponent('GameScore', {
  playerA: Schemas.Int,
  playerB: Schemas.Int
})

Step 2: Add Validation

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
})

Step 3: Server Creates & Modifies

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!
  })
}

Step 4: Clients Read the State

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')
}

Complete Working Example

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})`)
    })
  }
}

Development Workflow

  1. Start server: npx @dcl/hammurabi-server
  2. Make changes to your scene code
  3. Save - Server auto-reloads
  4. Test in preview
  5. Check logs - Server logs appear in terminal

Debugging Tips

1. Check Server is Running

ps aux | grep hammurabi

2. Add Logging

if (isServer()) {
  console.log('[SERVER] Starting...')
  room.onMessage('test', () => {
    console.log('[SERVER] Received test message')
  })
} else {
  console.log('[CLIENT] Starting...')
}

3. Verify Component Sync

// On server
console.log('Creating entity:', entityId)

// On client  
engine.addSystem(() => {
  const entities = Array.from(engine.getEntitiesWith(MyComponent))
  console.log('Synced entities:', entities.length)
})

Common Gotchas

❌ Don't: Forget validation

// BAD - Clients can modify!
const Score = engine.defineComponent('Score', schema)

✅ Do: Always validate

// GOOD - Server-only modifications
Score.validateBeforeChange((value) => {
  return value.senderAddress === AUTH_SERVER_PEER_ID
})

❌ Don't: Trust client data

// BAD
room.onMessage('setHealth', (data) => {
  player.health = data.health // Client controls health!
})

✅ Do: Validate on server

// GOOD
room.onMessage('takeDamage', (data) => {
  const damage = calculateDamage(data.source)
  player.health = Math.max(0, player.health - damage)
})

❌ Don't: Send huge messages frequently

// BAD - Sending every frame
engine.addSystem(() => {
  room.send('position', transform.position)
})

✅ Do: Throttle updates

// GOOD - Send periodically
let lastSend = 0
engine.addSystem((dt) => {
  lastSend += dt
  if (lastSend > 0.1) { // Every 100ms
    room.send('position', transform.position)
    lastSend = 0
  }
})

Migration from Colyseus

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

Performance: Component Design Best Practices

Unlike Colyseus which sends only diffs, every component change sends the entire component data. Design components carefully for network efficiency.

❌ Don't: Monolithic Components

// 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!
})

✅ Do: Atomic Components

// 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 bytes

Key rule: Group by update frequency - separate fast-changing from slow-changing data.

Remember: Never trust the client. Always validate on the server.

Testing & Development

Client for Testing

To properly test authoritative servers, you have two options:

Hammurabi Server Configuration

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-server

Current Limitation

⚠️ npx @dcl/hammurabi-server currently fails due to gatekeeper authentication issues.

Temporary solutions:


Clone this wiki locally