authoritative-server について
ヘッドレスサーバーがゲーム状態を管理し、変更を検証するDecentralandマルチプレイヤーシーンの開発スキルです。サーバーとクライアントの分岐、状態同期、メッセージング、永続化、環境設定を扱います。
主な機能
- 01isServer()による共有コードの分岐
- 02サーバー権威コンポーネント変更の検証
- 03エンティティと状態のクライアント同期
- 04クライアントとサーバー間のメッセージ交換
- 05ワールドとプレイヤーデータの永続化
活用シーン
- 01サーバーが状態を管理するマルチプレイヤーシーンの構築
- 02クライアント改ざんを防ぐプレイヤー操作の検証
- 03再起動後も維持するランキングやスコアの保存
- 04環境変数によるシーン動作の設定
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dcl-regenesislabs/ opendcl --skill authoritative-serverインストール後、Claude Code で/authoritative-serverを入力して読み込まれたか確認します。
同梱ファイルreferences/server-patterns.md
詳細
権威サーバーパターン
ヘッドレスサーバーがゲーム状態を管理し、変更を検証して、不正行為を防ぐマルチプレイヤーのDecentralandシーンを構築します。同じコードベースをサーバーとクライアントの両方で実行し、サーバーが完全な権限を持ちます。
サーバーを使わない基本的なCRDTマルチプレイヤー機能については、代わりにmultiplayer-syncスキルを参照してください。
セットアップ
1. auth-server SDKブランチをインストールする(必須)
必ずauth-serverタグを使用してください。標準の@dcl/sdkには権威サーバーAPI(isServer、registerMessages、Storage、EnvVarなど)が含まれていません。
npm install @dcl/sdk@auth-server
2. scene.jsonを設定する
scene.jsonには、次のプロパティを必ず含めてください。
authoritativeMultiplayer: true— 権威サーバーランタイムを有効にします。worldConfiguration.name— デプロイ、Storage、EnvVarで使用するワールドを識別します。logsPermissions— コンソールでサーバーログを確認できるウォレットアドレスの配列です。この設定がない場合、サーバーのconsole.log()出力は表示されません。
{
"authoritativeMultiplayer": true,
"worldConfiguration": {
"name": "my-world-name.dcl.eth"
},
"logsPermissions": ["0xYourWalletAddress"]
}
3. シーンを実行する
通常のプレビューを実行するだけで大丈夫です。scene.jsonでauthoritativeMultiplayer: trueが設定されていれば、バックグラウンドで権威サーバーが自動的に起動します。
デバッグに関する注意(ユーザーに実行するよう案内しないでください): プレビューでは、内部的に
npx @dcl/hammurabi-server@nextが実行されます。認証サーバーが起動しない場合は、hammurabiプロセスが実行中か確認し、出力にエラーがないか確認してください。
サーバーとクライアントの分岐
isServer()を使って、1つのコードベース内でロジックを分岐します。
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()
}
サーバーはシーンコードをヘッドレスで(レンダリングなしで)実行します。PlayerIdentityDataを通じてすべてのプレイヤーの位置にアクセスでき、権威のあるゲーム状態をすべて管理します。
検証付き同期コンポーネント
サーバーからすべてのクライアントに同期されるカスタムコンポーネントを定義します。クライアントがサーバー権威の状態を変更できないよう、必ずvalidateBeforeChange()を使用してください。
カスタムコンポーネント(グローバル検証)
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
})
組み込みコンポーネント(エンティティごとの検証)
TransformやGltfContainerなどの組み込みコンポーネントには、エンティティごとの検証を使用します。これにより、プレイヤー自身のエンティティに対するクライアント側のTransformをブロックせずに済みます。
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])
エンティティの同期
エンティティを作成して保護した後、すべてのクライアントに同期します。
import { syncEntity } from '@dcl/sdk/network'
syncEntity(entity, [Transform.componentId, GameState.componentId])
メッセージ
クライアントからサーバー、およびサーバーからクライアントへの通信にはregisterMessages()を使用します。
メッセージを定義する
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}`)
})
状態の同期を待つ
クライアントからメッセージを送信する前に、状態が同期されるまで待機します。
import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return
// Safe to send messages now
room.send('playerJoin', { displayName: 'Player' })
})
サーバーによるプレイヤー位置の読み取り
サーバーはプレイヤーの実際の位置を読み取れます。不正行為の防止に不可欠です。
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
}
})
クライアントから報告された位置を決して信用しないでください。サーバーで常にPlayerIdentityDataとTransformを読み取ってください。
Storage
サーバーの再起動後もデータを保持します。サーバー専用のため、isServer()でガードしてください。
import { Storage } from '@dcl/sdk/server'
ワールドストレージ(グローバル)
すべてのプレイヤーで共有されます。
// 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')
プレイヤーストレージ(プレイヤーごと)
プレイヤーのウォレットアドレスをキーとして使用します。
// 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が受け付けるのは文字列のみです。オブジェクトにはJSON.stringify()/JSON.parse()を、数値にはString()/parseInt()を使用してください。
ローカル開発環境のストレージはnode_modules/@dcl/sdk-commands/.runtime-data/server-storage.jsonにあります。
環境変数
値をハードコードせずにシーンを設定します。サーバー専用のため、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'
ローカル開発
プロジェクトのルートに.envファイルを作成します。
MAX_PLAYERS=8
GAME_DURATION=300
DEBUG=true
.envを.gitignoreに追加してください。
本番環境へのデプロイ
# Set a variable
npx sdk-commands deploy-env MAX_PLAYERS --value 8
# Delete a variable
npx sdk-commands deploy-env OLD_VAR --delete
デプロイ時の環境変数は、.envファイルの値より優先されます。
推奨されるプロジェクト構成
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()
同期コンポーネントとメッセージはshared/に配置し、サーバーとクライアントの両方が同じ定義をインポートするようにします。サーバーロジック(Storage、EnvVar、ゲームシステム)はserver/に配置します。UIとクライアント入力はclient/に配置します。
テストとデバッグ
- ログのプレフィックス:ターミナルでサーバーとクライアントの出力を区別するため、
console.log()では[Server]と[Client]のプレフィックスを使用します。 - 古い CRDT ファイル:「Outside of the bounds of written data」エラーが表示された場合は、
main.crdtとmain1.crdtファイルを削除して再起動します。 - ストレージの確認:ローカル開発中に永続化されたデータを確認するには、
node_modules/@dcl/sdk-commands/.runtime-data/server-storage.jsonを確認します。 - タイマー:ランタイムのポリフィルを通じて
setTimeout/setIntervalを使用できます。ゲームロジックでは、フレームループと同期を保つため、デルタタイムアキュムレーターとengine.addSystem()の使用を推奨します。 - エンティティ同期の問題:正しいコンポーネント ID(
MyComponent.componentId)を指定して、syncEntity(entity, [componentIds])を呼び出していることを確認します。
重要な注意事項
- タイムスタンプには
Schemas.Int64を使用:Schemas.Numberは大きな数値(13 桁以上)を破損させます。Date.now()などの値には、必ずSchemas.Int64を使用します。 - 状態同期の準備完了:クライアントはメッセージを送信する前に、
@dcl/sdk/networkのisStateSyncronized()がtrueを返すまで待つ必要があります。SDK には意図的なタイポがあり、「Synchronized」ではなく「Syncronized」となっている点に注意してください。 - カスタム検証と組み込み検証:カスタムコンポーネントではグローバルな
validateBeforeChange((value) => ...)を使用します。組み込みコンポーネント(Transform、GltfContainer)では、エンティティごとのvalidateBeforeChange(entity, (value) => ...)を使用します。 - 単一のコードベース:サーバーとクライアントは、どちらも同じ
index.tsエントリーポイントを実行します。分岐にはisServer()を使用します。 - Node.js API は使用不可:DCL ランタイムはサンドボックス化された QuickJS を使用するため、
fs、httpなどは利用できません。setTimeout/setIntervalはサポートされています。サーバー側の操作には、SDK が提供する API(Storage、EnvVar、engine systems)を使用します。 - SDK ブランチ(必須):auth-server パターンでは、標準の
@dcl/sdkではなく、npm install @dcl/sdk@auth-serverが必要です。これを使用しないと、isServer()、registerMessages()、Storage、EnvVarを利用できません。 scene.jsonの必須フィールド:authoritativeMultiplayer: trueを設定する必要があります。また、サーバーログを表示するウォレットアドレスをlogsPermissions: ["0xWalletAddress"]に列挙する必要があります。- サーバーを使用しない基本的な CRDT マルチプレイヤーについては、
multiplayer-syncスキルを参照してください。
サーバーの完全なセットアップ例、認証フロー、状態の調整、Storage のパターン、EnvVar の使用方法については、{baseDir}/references/server-patterns.md を参照してください。