-
Notifications
You must be signed in to change notification settings - Fork 27
Auth Server (With local storage)
A simple guide to building multiplayer scenes with server-side logic, persistent storage, and authoritative game state.
Install the SDK Package:
npm install @dcl/sdk@auth-server
Add your world to your scene.json:
{
"main": "bin/index.js",
"scene": {
"base": "0,0",
"parcels": ["0,0"]
},
"requiredPermissions": [],
"worldConfiguration": {
"name": "myWorld.dcl.eth"
}
}In your src/index.ts, import isServer to check the execution environment:
import { isServer } from '@dcl/sdk/network'
export function main() {
if (isServer()) {
console.log('[SERVER] Running on server')
// Server-side code
} else {
console.log('[CLIENT] Running on client')
// Client-side code
}
}That's it! You're ready to use server features. The following sections will show you how to use rooms, storage, and environment variables.
Rooms enable real-time communication between server and clients. Think of a room as a shared communication channel where the server and all connected clients can exchange messages.
Create a room using registerMessages():
import { registerMessages } from '@dcl/sdk/network'
import { Schemas } from '@dcl/sdk/ecs'
const room = registerMessages(Messages)The room object provides methods to:
-
room.send()- Send messages -
room.onMessage()- Listen for messages -
room.onReady()- Called when room is ready (providesisReady: boolean)
Define your message types and their data structure:
// 1. Define message type names
enum MessageType {
GREETING = 'GREETING',
PLAYER_ACTION = 'PLAYER_ACTION',
GAME_UPDATE = 'GAME_UPDATE'
}
// 2. Define message schemas (what data each message contains)
const Messages = {
[MessageType.GREETING]: Schemas.Map({
message: Schemas.String
}),
[MessageType.PLAYER_ACTION]: Schemas.Map({
action: Schemas.String,
timestamp: Schemas.Number
}),
[MessageType.GAME_UPDATE]: Schemas.Map({
score: Schemas.Int,
time: Schemas.Int,
isActive: Schemas.Boolean
})
}Available Schema Types:
-
Schemas.String- Text data -
Schemas.Int- Integer numbers -
Schemas.Number- Float numbers -
Schemas.Boolean- true/false -
Schemas.Optional(type)- Optional field
Register your messages to create the room:
const room = registerMessages(Messages)This must be done before calling main() so both server and client have access to the same room.
Everything can be in a single src/index.ts file:
import { isServer } from '@dcl/sdk/network'
import { registerMessages } from '@dcl/sdk/network'
import { Schemas } from '@dcl/sdk/ecs'
// Define message types
enum MessageType {
GREETING = 'GREETING'
}
// Define message schemas
const Messages = {
[MessageType.GREETING]: Schemas.Map({
message: Schemas.String
})
}
// Register messages and create room
const room = registerMessages(Messages)
// Main function
export function main() {
if (isServer()) {
// Server: Send greeting to all clients
console.log('[SERVER] Server started')
room.send(MessageType.GREETING, {
message: 'Hello from server!'
})
} else {
// Client: Listen for greeting from server
console.log('[CLIENT] Client started')
room.onMessage(MessageType.GREETING, (data) => {
console.log(`Greeting from server: ${data.message}`)
})
}
}Run npm run start and check the console - you'll see the server sending and clients receiving the message!
import { isServer } from '@dcl/sdk/network'
import { registerMessages } from '@dcl/sdk/network'
import { Schemas } from '@dcl/sdk/ecs'
enum MessageType {
PLAYER_ACTION = 'PLAYER_ACTION'
}
const Messages = {
[MessageType.PLAYER_ACTION]: Schemas.Map({
action: Schemas.String
})
}
const room = registerMessages(Messages)
export function main() {
if (isServer()) {
// Server receives from clients
room.onMessage(MessageType.PLAYER_ACTION, (data, context) => {
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
console.log(`Player ${userId} performed: ${data.action}`)
})
} else {
// Client sends to server (e.g., when player presses a button)
room.send(MessageType.PLAYER_ACTION, {
action: 'jump'
})
}
}export function main() {
if (isServer()) {
// Server broadcasts to all connected clients
room.send(MessageType.GAME_UPDATE, {
score: 100,
time: 30
})
} else {
// All clients receive the message
room.onMessage(MessageType.GAME_UPDATE, (data) => {
console.log(`Score: ${data.score}, Time: ${data.time}`)
})
}
}export function main() {
// Called on both client and server when room is ready
room.onReady((isReady) => {
if (isReady) {
console.log('[ROOM] Room is ready!')
if (isServer()) {
console.log('[SERVER] Server room ready')
// Initialize server state
} else {
console.log('[CLIENT] Client room ready')
// Request initial state from server
room.send(MessageType.GET_INITIAL_STATE, {})
}
}
})
}Storage provides persistent data that survives between sessions. There are two types: World Storage (global) and Player Storage (per-player).
isServer() check.
import { isServer } from '@dcl/sdk/network'
import { Storage } from '@dcl/sdk/server'
export function main() {
if (isServer()) {
// ✅ Safe - Storage runs on server
await Storage.world.set('key', 'value')
} else {
// ❌ Error - Storage will throw error on client
}
}Local Development:
During local development, all storage is saved to a local file to simulate production:
node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json
You can:
- Inspect this file to see your stored data
- Delete it to reset all storage during testing
- Keep it to persist data between development sessions
Production:
In production, storage is saved to Decentraland's world storage servers. Your data persists across:
- Server restarts
- Scene redeployments
- Player sessions
The same code works in both environments - the SDK handles the differences automatically!
World storage is shared across all players:
import { Storage } from '@dcl/sdk/server'
// Set world data
await Storage.world.set('globalCounter', '42')
await Storage.world.set('highScore', '9999')
// Get world data
const counter = await Storage.world.get<string>('globalCounter')
console.log('Global counter:', counter) // '42'
// Delete world data
await Storage.world.delete('oldData')Player storage is unique per player:
// In a message handler
room.onMessage(MessageType.SAVE_SCORE, async (data, context) => {
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
// Set player data
await Storage.player.set(userId, 'score', String(data.score))
await Storage.player.set(userId, 'level', String(data.level))
console.log(`Saved ${userId}'s score: ${data.score}`)
})
// Get player data
room.onMessage(MessageType.LOAD_SCORE, async (data, context) => {
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
const score = await Storage.player.get<string>(userId, 'score')
const level = await Storage.player.get<string>(userId, 'level')
// Send back to client
room.send(MessageType.SCORE_RESPONSE, {
score: score || '0',
level: level || '1'
})
})Storage only accepts strings, so use JSON for objects:
// Save object as JSON
const playerData = {
name: 'Alice',
inventory: ['sword', 'shield'],
stats: { hp: 100, mp: 50 }
}
await Storage.player.set(userId, 'data', JSON.stringify(playerData))
// Load and parse JSON
const dataJson = await Storage.player.get<string>(userId, 'data')
const playerData = dataJson ? JSON.parse(dataJson) : nullEnvironment variables let you configure your scene without changing code. They are deployed separately from your scene code.
isServer() check.
import { isServer } from '@dcl/sdk/network'
import { EnvVar } from '@dcl/sdk/server'
export async function main() {
if (isServer()) {
// ✅ Safe - EnvVar runs on server
const maxPlayers = (await EnvVar.get('MAX_PLAYERS')) || '4'
} else {
// ❌ Error - EnvVar will throw error on client
}
}Option 1: Using .env file
Create a .env file in your project root:
# .env
MAX_PLAYERS=8
GAME_DURATION=300
DIFFICULTY=hard
MUSIC_URL=https://example.com/song.mp3Important: Add .env to your .gitignore to avoid committing secrets!
Option 2: Deploy to local development server
You can also deploy env vars to your running local development server:
# Start your local server first
npm run start
# In another terminal, deploy env vars to local server
npx sdk-commands deploy-env MAX_PLAYERS --value 8 --target http://localhost:8000
npx sdk-commands deploy-env DIFFICULTY -v hard --target http://localhost:8000
# Delete env var from local server
npx sdk-commands deploy-env OLD_VAR --delete --target http://localhost:8000Precedence: Deployed env vars (Option 2) take precedence over .env file (Option 1).
During local development, the server stores data in a local file to simulate production:
node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json
This file contains:
- World Storage - Global data shared across all players
- Player Storage - Per-player data
- Environment Variables - Deployed env vars (from Option 2)
You can inspect or clear this file for testing purposes.
Use the sdk-commands CLI to deploy environment variables:
# Set an environment variable
npx sdk-commands deploy-env MAX_PLAYERS --value 8
npx sdk-commands deploy-env MUSIC_URL -v "https://example.com/song.mp3"
# Delete an environment variable
npx sdk-commands deploy-env OLD_VAR --delete
npx sdk-commands deploy-env OLD_VAR -d
# Deploy to specific zone
npx sdk-commands deploy-env MAX_PLAYERS --value 8 --target https://storage.decentraland.zoneimport { EnvVar } from '@dcl/sdk/server'
export async function initServer() {
// Get environment variables
const maxPlayers = parseInt((await EnvVar.get('MAX_PLAYERS')) || '4')
const gameDuration = parseInt((await EnvVar.get('GAME_DURATION')) || '180')
const difficulty = (await EnvVar.get('DIFFICULTY')) || 'normal'
const musicUrl = (await EnvVar.get('MUSIC_URL')) || ''
console.log('[SERVER] Config:', {
maxPlayers,
gameDuration,
difficulty,
musicUrl
})
// Use in your game logic
if (players.length >= maxPlayers) {
console.log('Game is full!')
}
}
// Get all environment variables
const allVars = await EnvVar.all()
console.log('[SERVER] All env vars:', allVars)Environment variables are always strings, so convert as needed:
// String
const name = (await EnvVar.get('WORLD_NAME')) || 'My World'
// Number
const maxScore = parseInt((await EnvVar.get('MAX_SCORE')) || '1000')
// Float
const multiplier = parseFloat((await EnvVar.get('MULTIPLIER')) || '1.5')
// Boolean
const debugMode = ((await EnvVar.get('DEBUG')) || 'false') === 'true'
// Array (comma-separated)
const levels = ((await EnvVar.get('LEVELS')) || 'easy,medium,hard').split(',')
// JSON
const configJson = (await EnvVar.get('CONFIG')) || '{}'
const config = JSON.parse(configJson)Never trust client data. Always validate on the server:
// ❌ Bad - Client sends score
room.onMessage(MessageType.REPORT_SCORE, async (data, context) => {
const userId = context?.from || 'unknown'
await Storage.player.set(userId, 'score', String(data.score)) // Could be cheated!
})
// ✅ Good - Server calculates score
room.onMessage(MessageType.HIT_TARGET, async (data, context) => {
const userId = context?.from || 'unknown'
// Validate hit on server
const isValid = validateHit(data.timestamp, data.targetId)
if (!isValid) return
// Calculate score on server
const points = calculatePoints(data.accuracy)
// Update storage
const currentScore = await Storage.player.get<string>(userId, 'score')
const newScore = parseInt(currentScore || '0') + points
await Storage.player.set(userId, 'score', String(newScore))
})Storage returns undefined if key doesn't exist:
// Always provide defaults
const score = await Storage.player.get<string>(userId, 'score')
const scoreValue = score ? parseInt(score) : 0
// Or with JSON
const dataJson = await Storage.player.get<string>(userId, 'data')
const data = dataJson ? JSON.parse(dataJson) : { score: 0, level: 1 }Use prefixes for easy debugging:
// Server
console.log('[SERVER] Player joined:', userId)
console.log('[SERVER][STORAGE] Saved player data')
console.error('[SERVER][ERROR] Failed to load config')
// Client
console.log('[CLIENT] Received update')
console.log('[CLIENT][INPUT] Key pressed:', key)Use appropriate schema types:
const Messages = {
[MessageType.PLAYER_ACTION]: Schemas.Map({
action: Schemas.String, // Use String for text
timestamp: Schemas.Number, // Use Number for floats/timestamps
count: Schemas.Int, // Use Int for integers
isActive: Schemas.Boolean, // Use Boolean for true/false
optional: Schemas.Optional(Schemas.String) // Use Optional for nullable fields
})
}interface LeaderboardEntry {
userId: string
name: string
score: number
}
async function updateLeaderboard(userId: string, name: string, score: number) {
// Get current leaderboard
const leaderboardJson = await Storage.world.get<string>('leaderboard')
const leaderboard: LeaderboardEntry[] = leaderboardJson ? JSON.parse(leaderboardJson) : []
// Add new entry
leaderboard.push({ userId, name, score })
// Sort by score (descending)
leaderboard.sort((a, b) => b.score - a.score)
// Keep top 10
const top10 = leaderboard.slice(0, 10)
// Save back
await Storage.world.set('leaderboard', JSON.stringify(top10))
// Broadcast to all players
room.send(MessageType.LEADERBOARD_UPDATE, {
leaderboard: JSON.stringify(top10)
})
}// In-memory state (resets when server restarts)
const activePlayers = new Map<string, PlayerSession>()
// Track players via messages
room.onMessage(MessageType.PLAYER_JOIN, async (data, context) => {
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
// Store in memory for quick access
activePlayers.set(userId, {
userId,
connectedAt: Date.now(),
isPlaying: false
})
console.log('[SERVER] Player joined:', userId)
})
room.onMessage(MessageType.PLAYER_LEAVE, async (data, context) => {
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
const session = activePlayers.get(userId)
if (session) {
// Save important data to storage before removing
await Storage.player.set(userId, 'lastSeen', String(Date.now()))
activePlayers.delete(userId)
}
console.log('[SERVER] Player left:', userId)
})Always handle errors in async operations:
room.onMessage(MessageType.SAVE_DATA, async (data, context) => {
const userId = context?.from || 'unknown'
try {
await Storage.player.set(userId, 'data', JSON.stringify(data))
room.send(MessageType.SAVE_SUCCESS, {
message: 'Data saved successfully'
})
} catch (error) {
console.error('[SERVER][ERROR] Failed to save data:', error)
room.send(MessageType.SAVE_ERROR, {
message: 'Failed to save data'
})
}
})The local storage file helps you test your scene:
Location: node_modules/@dcl/sdk-commands/.runtime-data/server-storage.json
Testing workflow:
- Run
npm run startto start local server - Interact with your scene (store data)
- Check
server-storage.jsonto verify data is saved correctly - Delete the file to reset and test fresh state
- Deploy env vars with
--target http://localhost:8000to test production-like configuration
Note: This file contains World Storage, Player Storage, and deployed Environment Variables.
Here's a complete counter example in a single file:
1. Create .env file for local development:
# .env
MAX_COUNT=1002. Deploy to production:
npx sdk-commands deploy-env MAX_COUNT --value 1003. Configure scene.json:
{
"authoritativeMultiplayer": true,
"multiplayerId": "counter-world",
"worldConfiguration": {
"name": "counter.dcl.eth"
}
}4. Use in code (src/index.ts):
import { isServer } from '@dcl/sdk/network'
import { registerMessages } from '@dcl/sdk/network'
import { Schemas, engine } from '@dcl/sdk/ecs'
import { Storage, EnvVar } from '@dcl/sdk/server'
import { Transform, MeshRenderer, MeshCollider, pointerEventsSystem } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
// Define messages
enum MessageType {
INCREMENT = 'INCREMENT',
COUNTER_UPDATE = 'COUNTER_UPDATE'
}
const Messages = {
[MessageType.INCREMENT]: Schemas.Map({}),
[MessageType.COUNTER_UPDATE]: Schemas.Map({
global: Schemas.Int,
player: Schemas.Int
})
}
const room = registerMessages(Messages)
// Main function
export async function main() {
if (isServer()) {
// SERVER LOGIC
const maxCount = parseInt((await EnvVar.get('MAX_COUNT')) || '100')
room.onMessage(MessageType.INCREMENT, async (data, context) => {
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
// Get global counter
const globalValue = await Storage.world.get<string>('counter')
const global = globalValue ? parseInt(globalValue) : 0
if (global >= maxCount) return
// Increment global
const newGlobal = global + 1
await Storage.world.set('counter', String(newGlobal))
// Increment player
const playerValue = await Storage.player.get<string>(userId, 'clicks')
const player = playerValue ? parseInt(playerValue) : 0
const newPlayer = player + 1
await Storage.player.set(userId, 'clicks', String(newPlayer))
// Broadcast
room.send(MessageType.COUNTER_UPDATE, {
global: newGlobal,
player: newPlayer
})
})
} else {
// CLIENT LOGIC
let globalCounter = 0
let myCounter = 0
// Listen for updates
room.onMessage(MessageType.COUNTER_UPDATE, (data) => {
globalCounter = data.global
myCounter = data.player
console.log(`Global: ${globalCounter}, Mine: ${myCounter}`)
})
// Create clickable cube
const cube = engine.addEntity()
Transform.create(cube, { position: Vector3.create(8, 1, 8) })
MeshRenderer.setBox(cube)
MeshCollider.setBox(cube)
pointerEventsSystem.onPointerDown(
{ entity: cube, opts: { button: 0, hoverText: 'Click me!' } },
() => room.send(MessageType.INCREMENT, {})
)
}
}Run npm run start, click the cube, and watch the counters increment!
For larger projects, you can organize your code into separate files:
src/
├── index.ts # Main entry, calls initServer() or initClient()
├── shared/
│ └── index.ts # Message types, schemas, and room
├── server/
│ └── index.ts # Server logic
└── client/
└── index.ts # Client logic
See the full rhythm game implementation in this project for an example of organized code structure.
// Check environment
import { isServer } from '@dcl/sdk/network'
// Create room
import { registerMessages } from '@dcl/sdk/network'
import { Schemas } from '@dcl/sdk/ecs'
// Server-only imports
import { Storage, EnvVar } from '@dcl/sdk/server'// Register messages and create room
const room = registerMessages(Messages)
// Send message (server or client)
room.send(MessageType.MESSAGE_NAME, { data })
// Receive message (server or client)
room.onMessage(MessageType.MESSAGE_NAME, (data, context) => {
// context.from contains userId (string or undefined)
const userId = typeof context?.from === 'string' ? context.from : 'unknown'
})
// OnReady callback (called when room is ready)
room.onReady((isReady: boolean) => {
if (isReady) {
console.log('Room is ready!')
}
})// World storage (global)
await Storage.world.set(key, value)
await Storage.world.get<string>(key)
await Storage.world.delete(key)
// Player storage (per-player)
await Storage.player.set(userId, key, value)
await Storage.player.get<string>(userId, key)
await Storage.player.delete(userId, key)// Get single variable
const value = (await EnvVar.get('KEY')) || 'default'
// Get all variables
const all = await EnvVar.all()For more complex examples, see:
-
src/server/index.ts- Full rhythm game server with game state management -
src/shared/index.ts- Complete message schema definitions -
src/client/index.ts- Client-side game logic and UI
Made with ❤️ for Decentraland builders