multiplayer-sync について
Decentralandのマルチプレイヤーシーン開発者向け同期スキルです。プレイヤー間でエンティティ状態を共有し、永続状態、一時メッセージ、外部サーバー通信を使い分ける方法を解説します。カスタムコンポーネントや接続状態の扱いも含みます。
主な機能
- 01エンティティのコンポーネントと状態の同期
- 02MessageBusによる一時メッセージ送信
- 03REST、signedFetch、WebSocketでの通信
- 04カスタム同期コンポーネントの定義
- 05プレイヤー識別情報と同期状態の確認
活用シーン
- 01プレイヤー間でドアやスコアボードを共有
- 02チャットや効果音などの一時イベント配信
- 03外部サーバーへのデータ永続化
- 04リアルタイムなサーバー通信の構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dcl-regenesislabs/ opendcl --skill multiplayer-syncインストール後、Claude Code で/multiplayer-syncを入力して読み込まれたか確認します。
同梱ファイルreferences/networking-patterns.md
詳細
Decentraland でのマルチプレイヤー同期
作成の分割
syncEntity() は実行時に NetworkEntity コンポーネントを追加します。NetworkEntity は main-entities.ts のサポート対象コンポーネント一覧には含まれていません。次のように使い分けます。
- 事前定義された同期エンティティ(ドア、スイッチ、スコアボードなど、作成時に決まっているもの):ビジュアルコンポーネントとともに
main-entities.tsで宣言します。src/index.tsで参照し、そこでsyncEntity(entity, [...componentIds], SyncIds.NAME)を呼び出します。 - 動的に生成される同期エンティティ(弾、ドロップアイテム、実行時に作成されるゲームオブジェクト):
src/index.tsでengine.addEntity()を使って作成し、すぐにsyncEntityを呼び出します。
// main-entities.ts
door: {
components: {
Transform: { position: { x: 8, y: 1, z: 8 } },
MeshRenderer: { mesh: { $case: 'box', box: { uvs: [] } } }
}
}
// src/index.ts
import { engine, Transform, syncEntity } from '@dcl/sdk/network'
export function main() {
const door = engine.getEntityOrNullByName('door')
if (door) syncEntity(door, [Transform.componentId], SyncIds.DOOR)
}
Decentraland のシーンは本質的にマルチプレイヤーです。同じシーンにいるすべてのプレイヤーが、同じ空間を共有します。ただし、デフォルトではプレイヤーは環境と個別にやり取りするため、あるプレイヤーが行った変更はほかのプレイヤーとは共有されません。 変更可能な状態を syncEntity でラップする、MessageBus でイベントをブロードキャストする、または独自のバックエンドに保存することで、共有を有効にできます。
syncEntity の状態は、少なくとも1人のプレイヤーがシーンに残っている間だけ保持されます。シーンが空になると、状態はリセットされます。シーンのリセット後も状態を保持するには、独自のサーバーに保存してください。
実行時の制約: Decentraland は QuickJS サンドボックスで動作します。Node.js の API(
fs、http、path、process)は利用できません。ネットワーク通信にはfetch()とWebSocketを使用してください。非同期処理のパターンについては、scene-runtime スキルを参照してください。
同期戦略の判断ツリー
必要に応じて、適切なネットワーク通信方式を選択します。
| 戦略 | 用途 | 永続性 | 例 |
|---|---|---|---|
syncEntity | すべてのプレイヤーが同じ状態を確認でき、新たに参加したプレイヤーにも引き継がれる共有状態 | あり — プレイヤーの参加・退出後も状態が保持される | ドア、スイッチ、スコアボード、エレベーター |
MessageBus | その場限りのイベント | なし — 後から参加したプレイヤーは過去のメッセージを受け取れない | チャットメッセージ、効果音、パーティクルのトリガー |
fetch / REST API | 外部サーバー上のデータの読み取りまたは書き込み | サーバーに依存 | リーダーボード、インベントリ、外部ゲーム状態 |
signedFetch | プレイヤーの本人確認を伴う認証済みリクエスト | サーバーに依存 | 報酬の受け取り、検証済みスコアの送信 |
WebSocket | サーバーとのリアルタイム双方向通信 | 接続に依存 | ライブゲームサーバー、リアルタイムチャット、サーバー権威型マルチプレイヤー |
判断の流れ:
- 後から参加したプレイヤーも含め、すべてのプレイヤーが同じ状態を確認する必要がありますか? -->
syncEntity - 現在シーンにいるプレイヤーだけを対象とした、送信後の応答を必要としないイベントですか? -->
MessageBus - 全プレイヤーが退出した後も状態を保持する必要がありますか。または、サーバー側での検証が必要ですか? -->
fetchまたはsignedFetchを使った外部サーバー - サーバーとの継続的なリアルタイム通信が必要ですか? -->
WebSocket - 方式は自由に組み合わせられます。ワールドの状態には
syncEntity、エフェクトにはMessageBus、永続化にはfetchを使用してください。
syncEntity の基本
インポートと基本的な使い方
import { engine, Transform, MeshRenderer, Material } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
import { Vector3, Color4 } from '@dcl/sdk/math'
シグネチャ:syncEntity(entity, componentIds[], syncId?)
entity— 同期するエンティティcomponentIds[]— 同期対象のコンポーネント ID の配列(例:[Transform.componentId])syncId— 一意の数値識別子(事前定義されたエンティティでは必須、プレイヤーが生成するエンティティでは省略可能)
Enum の同期 ID(事前定義されたエンティティ)
事前定義された同期エンティティには、それぞれ一意の数値 ID を割り当てる必要があります。衝突を避けるため、enum を使用してください。
enum SyncIds {
DOOR = 1,
ELEVATOR = 2,
SCOREBOARD = 3
}
const door = engine.addEntity()
Transform.create(door, { position: Vector3.create(8, 1, 8) })
MeshRenderer.setBox(door)
syncEntity(door, [Transform.componentId, MeshRenderer.componentId], SyncIds.DOOR)
同期 ID を持つ事前定義されたエンティティは、作成したプレイヤーが退出した後も残ります。プレイヤーが作成したエンティティ(同期 ID なし)は、プレイヤーが切断すると削除されます。
自動生成 ID(プレイヤーが生成するエンティティ)
プレイヤーが実行時に作成するエンティティには、明示的な同期 ID は必要ありません。
function createProjectile() {
const projectile = engine.addEntity()
Transform.create(projectile, { position: Vector3.create(4, 1, 4) })
MeshRenderer.setSphere(projectile)
syncEntity(projectile, [Transform.componentId])
return projectile
}
カスタム同期コンポーネント
カスタムコンポーネントを定義し、プレイヤー間で同期します。
import { engine, Schemas } from '@dcl/sdk/ecs'
import { syncEntity } from '@dcl/sdk/network'
const ScoreBoard = engine.defineComponent('scoreBoard', {
score: Schemas.Int,
playerName: Schemas.String,
lastUpdated: Schemas.Int64
})
const board = engine.addEntity()
ScoreBoard.create(board, { score: 0, playerName: '', lastUpdated: 0 })
syncEntity(board, [ScoreBoard.componentId])
function addScore(points: number) {
const data = ScoreBoard.getMutable(board)
data.score += points
data.lastUpdated = Date.now()
}
プレイヤー固有のデータ
PlayerIdentityData を使ってプレイヤーを区別します。
import { engine, PlayerIdentityData } from '@dcl/sdk/ecs'
engine.addSystem(() => {
for (const [entity] of engine.getEntitiesWith(PlayerIdentityData)) {
const data = PlayerIdentityData.get(entity)
console.log('Player:', data.address, 'Guest:', data.isGuest)
}
})
スキーマ型
カスタムコンポーネントで使用できるスキーマ型:
| 型 | 用途 |
|---|---|
Schemas.Boolean | true/false |
Schemas.Int | 整数 |
Schemas.Float | 小数 |
Schemas.String | テキスト文字列 |
Schemas.Int64 | 大きな整数(タイムスタンプ) |
Schemas.Vector3 | 3D 座標 |
Schemas.Quaternion | 回転 |
Schemas.Color3 | RGB カラー |
Schemas.Color4 | RGBA カラー |
Schemas.Entity | エンティティ参照 |
Schemas.Array(innerType) | 値の配列 |
Schemas.Map(valueType) | キーと値のマップ |
Schemas.Optional(innerType) | null 許容値 |
Schemas.Enum(enumType) | 列挙型の値 |
親子間の同期関係
親子関係のある同期対象エンティティでは、Transform.parent を設定する代わりに parentEntity() を使用します。
import { syncEntity, parentEntity, getParent, getChildren, removeParent } from '@dcl/sdk/network'
const parent = engine.addEntity()
const child = engine.addEntity()
syncEntity(parent, [Transform.componentId], 1)
syncEntity(child, [Transform.componentId], 2)
// Use parentEntity() — NOT Transform.parent
parentEntity(child, parent)
const parentRef = getParent(child)
const childrenArray = Array.from(getChildren(parent))
// Remove parent relationship
removeParent(child)
接続状態
プレイヤーが同期ルームに接続されているか確認します。
import { isStateSyncronized } from '@dcl/sdk/network'
engine.addSystem(() => {
if (!isStateSyncronized()) return // wait for sync
// safe to read/write synced state
})
注: SDK では、関数名は isStateSyncronized(「Synchronized」ではありません)と綴られています。
MessageBus
プレイヤー間でカスタムメッセージを送信します(送信後の応答待ちなし、永続化なし)。
import { MessageBus } from '@dcl/sdk/message-bus'
const bus = new MessageBus()
bus.on('hit', (data: { damage: number }) => {
console.log('Took damage:', data.damage)
})
bus.emit('hit', { damage: 10 })
syncEntity と MessageBus の比較
syncEntity:状態は永続化され、後から参加したプレイヤーも現在の状態を取得でき、自動的に競合が解決されますMessageBus:送信後の応答待ちなし。後から参加したプレイヤーは過去のメッセージを受信できません。一時的な効果に適しています- 両方を組み合わせます。ドアの開閉状態には
syncEntityを使い、ドアが開いたときの効果音にはMessageBusを使います
REST API 呼び出し(fetch)
SDK ランタイムはトップレベルの await をサポートしていないため、すべてのネットワーク呼び出しは executeTask 内で実行する必要があります。
import { executeTask } from '@dcl/sdk/ecs'
executeTask(async () => {
try {
const response = await fetch('https://api.example.com/data')
if (!response.ok) {
console.error('HTTP error:', response.status)
return
}
const data = await response.json()
console.log('Response:', data)
} catch (error) {
console.error('Network error:', error)
}
})
署名付き Fetch(認証済みリクエスト)
signedFetch は、プレイヤーの本人性を証明する暗号署名を付加します。
import { signedFetch } from '~system/SignedFetch'
executeTask(async () => {
try {
const response = await signedFetch({
url: 'https://example.com/api/action',
init: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'claimReward', amount: 100 })
}
})
if (!response.ok) {
console.error('HTTP error:', response.status)
return
}
const result = JSON.parse(response.body)
console.log('Result:', result)
} catch (error) {
console.log('Request failed:', error)
}
})
WebSocket 接続
再接続、ハートビート、メッセージ形式など、WebSocket の詳細なパターンについては {baseDir}/references/networking-patterns.md を参照してください。
基本的な接続
executeTask(async () => {
const ws = new WebSocket('wss://example.com/ws')
ws.onopen = () => {
console.log('Connected to WebSocket')
ws.send(JSON.stringify({ type: 'join', playerId: 'player123' }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'gameState': handleGameState(msg); break
case 'playerJoin': handlePlayerJoin(msg); break
case 'playerLeave': handlePlayerLeave(msg); break
}
}
ws.onerror = (error) => console.error('WebSocket error:', error)
ws.onclose = () => console.log('Disconnected')
})
プレイヤーの入退室イベント
プレイヤーがシーンに入ったとき、または退出したときを検出します。
import { onEnterScene, onLeaveScene } from '@dcl/sdk/src/players'
onEnterScene((player) => {
console.log('Player entered:', player.userId)
})
onLeaveScene((userId) => {
console.log('Player left:', userId)
})
マルチプレイヤーのテスト
ローカルでマルチプレイヤーをテストするには、複数のブラウザーウィンドウを開きます。各ウィンドウは別々のプレイヤーになります。
オフラインモード
マルチプレイヤー機能が不要な Decentraland Worlds 向け:
{
"worldConfiguration": {
"fixedAdapter": "offline:offline"
}
}
トラブルシューティング
| 問題 | 原因 | 解決策 |
|---|---|---|
| プレイヤー間で状態が同期されない | syncEntity() の呼び出しがない | 共有するすべてのエンティティで syncEntity(entity, [ComponentId1, ComponentId2]) を呼び出します |
| 同期 ID の衝突 | 2 つのエンティティが同じ数値の同期 ID を共有している | 列挙型を使って、事前定義された同期対象エンティティごとに一意の ID を割り当てます |
| 作成者が退出するとエンティティが消える | 同期 ID が指定されていない | 永続化するエンティティについては、syncEntity() に同期 ID(第 3 引数)を追加します |
Date.now() の値が破損する | タイムスタンプに Schemas.Number を使用している | Date.now() のように 13 桁を超える数値には Schemas.Int64 を使用します |
| 参加時に状態が準備できていない | 同期が完了する前に同期状態を読み取っている | システム内で if (!isStateSyncronized()) return を使って処理をガードします |
| MessageBus のメッセージが失われる | 後から参加したプレイヤーが過去のメッセージを想定している | MessageBus は送信後の応答待ちを行いません。永続的な状態には syncEntity を使用します |
サーバー側の検証やチート対策が必要ですか? ヘッドレスサーバーのパターンについては、authoritative-server スキルを参照してください。
重要な注意事項
- エンティティは
syncEntity(entity, [componentIds])を使って明示的に同期する必要があります — 同期する各コンポーネントのcomponentIdを渡してください - CRDT の解決:2 人のプレイヤーが同じコンポーネントを同時に変更した場合、後から書き込んだ値が優先されます
- サーバー側のコードはありません:Decentraland のシーンは CRDT 同期を使って、すべてクライアント側で実行されます
- エンティティ数の上限が適用されます:同期された各エンティティは、シーンのエンティティ予算に加算されます
- カスタムスキーマは決定的である必要があります:コンポーネント名が同じ場合、すべてのクライアントでスキーマも同一である必要があります
- タイムスタンプには
Schemas.Int64を使ってください:Schemas.Numberは大きな数値(13 桁以上)を破損させます。Date.now()のような値には、必ずSchemas.Int64を使ってください - 検証機能とチート対策を備えたサーバー権威型マルチプレイヤーについては、
authoritative-serverスキルを参照してください