mcpField
A
authoritative-server

authoritative-server

github.com/dcl-regenesislabs開発1737 回インストール21 週間前に更新
リポジトリ

authoritative-server について

ヘッドレスサーバーがゲーム状態を管理し、変更を検証するDecentralandマルチプレイヤーシーンの開発スキルです。サーバーとクライアントの分岐、状態同期、メッセージング、永続化、環境設定を扱います。

主な機能

  1. 01isServer()による共有コードの分岐
  2. 02サーバー権威コンポーネント変更の検証
  3. 03エンティティと状態のクライアント同期
  4. 04クライアントとサーバー間のメッセージ交換
  5. 05ワールドとプレイヤーデータの永続化

活用シーン

  1. 01サーバーが状態を管理するマルチプレイヤーシーンの構築
  2. 02クライアント改ざんを防ぐプレイヤー操作の検証
  3. 03再起動後も維持するランキングやスコアの保存
  4. 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 を参照してください。

コメント