mcpField
M
multiplayer-sync

multiplayer-sync

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

multiplayer-sync について

Decentralandのマルチプレイヤーシーン開発者向け同期スキルです。プレイヤー間でエンティティ状態を共有し、永続状態、一時メッセージ、外部サーバー通信を使い分ける方法を解説します。カスタムコンポーネントや接続状態の扱いも含みます。

主な機能

  1. 01エンティティのコンポーネントと状態の同期
  2. 02MessageBusによる一時メッセージ送信
  3. 03REST、signedFetch、WebSocketでの通信
  4. 04カスタム同期コンポーネントの定義
  5. 05プレイヤー識別情報と同期状態の確認

活用シーン

  1. 01プレイヤー間でドアやスコアボードを共有
  2. 02チャットや効果音などの一時イベント配信
  3. 03外部サーバーへのデータ永続化
  4. 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サーバーとのリアルタイム双方向通信接続に依存ライブゲームサーバー、リアルタイムチャット、サーバー権威型マルチプレイヤー

判断の流れ:

  1. 後から参加したプレイヤーも含め、すべてのプレイヤーが同じ状態を確認する必要がありますか? --> syncEntity
  2. 現在シーンにいるプレイヤーだけを対象とした、送信後の応答を必要としないイベントですか? --> MessageBus
  3. 全プレイヤーが退出した後も状態を保持する必要がありますか。または、サーバー側での検証が必要ですか? --> fetch または signedFetch を使った外部サーバー
  4. サーバーとの継続的なリアルタイム通信が必要ですか? --> WebSocket
  5. 方式は自由に組み合わせられます。ワールドの状態には 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.Booleantrue/false
Schemas.Int整数
Schemas.Float小数
Schemas.Stringテキスト文字列
Schemas.Int64大きな整数(タイムスタンプ)
Schemas.Vector33D 座標
Schemas.Quaternion回転
Schemas.Color3RGB カラー
Schemas.Color4RGBA カラー
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 スキルを参照してください

コメント