【Godot】Godotのマルチプレイ同期入門:参加者が撃った弾と敵を、全員の画面でそろえる

作成: 2026-09-23最終更新: 2026-10-10

MultiplayerSpawnerとSynchronizer、@rpcを使い分けて、参加者が撃った弾と敵を全員の画面でそろえる方法を解説します。

LANでつないで2人のキャラが動いたら(LANマルチプレイ入門)、次は弾と敵を全員の画面でそろえます。キャラは持ち主が自分の画面で動かしますが、弾と敵はホストが出して、動かして、当たりを決めます。ほかの画面はそれを写し、参加者はホストに「撃ちたい」と頼みます。

この分け方をせずに撃った人の画面で弾を出すと、参加者が撃った弾はホストの画面に出ません。敵も、画面ごとに違う場所を歩きます。

この記事では、MultiplayerSpawner・MultiplayerSynchronizer・@rpc を使い分けて、弾と敵を全員の画面でそろえる方法を解説します。

左右2つのウィンドウに同じ3体の敵と2人のキャラが映り、右の参加者のキャラが撃った弾が、両方の画面で同じ位置を飛んでいる

この記事でわかること

  • キャラは持ち主が、弾と敵はホストが動かすという分け方
  • 出す・写す・頼むの3つと、それぞれに使う道具
  • @rpc に any_peer と call_local を付ける場面
  • 当たりとHPをホストだけで決めて、倒した敵を全員の画面から消す方法

動作確認環境: Godot 4.7

Sponsored

持ち主(authority)の分け方

LANのマルチプレイでは、画面の数だけ同じシーンのコピーがあります。キャラには持ち主(authority。そのキャラを動かしていい人)がいて、持ち主の画面で動かした位置を、ほかの画面へ写します。

弾も撃った人のものにしたくなりますが、弾と敵の位置は、通信の遅れの分だけ画面ごとにずれています。当たったかどうかを画面ごとに決めると、同じ弾が片方の画面では当たり、もう片方では外れて、敵のHPが画面ごとに違ってきます。

そこで弾と敵は、全部ホストのものにします。出すのも、動かすのも、当たりを決めるのもホストです。ほかの画面は、ホストのコピーを写すだけにします。

��キャラの「1」と「2584」はそれぞれの持ち主が動かし、弾と敵は全部ホストの「1」が持ち主で、ホストが動かす

図の「1」はホストの番号です。参加者の番号は、つなぐたびに決まる数で、「2584」はその例です。

使う道具は3つです。

  • 出す: ホストが add_child() した弾と敵を、MultiplayerSpawner がほかの画面にも出す
  • 写す: 敵と弾の位置、敵のHPを、MultiplayerSynchronizer がホストからほかの画面へ写す
  • 頼む: 参加者は自分で弾を出さず、@rpc を付けた関数でホストに「撃ちたい」と頼む
参加者が@rpcでホストに頼み、ホストがMultiplayerSpawnerで出してSynchronizerで写し、両方に同じ弾が飛ぶ

MultiplayerSpawnerで出す

参加者の画面で弾のシーンから1つ作り(instantiate())、add_child() で足すと、その画面には弾が出ます。エラーも出ません。ただ、ホストの画面には出ません。MultiplayerSpawner がほかの画面へ届けるのは、ホストが足した物だけです。それも、Spawn Path のノードの直下に足した、Auto Spawn List にあるシーンだけです。弾をキャラの子に足すと、ほかの画面には出ません。

参加者が足した弾は参加者の画面にしか出ず、ホストが足した弾は MultiplayerSpawner で参加者の画面にも出る

MultiplayerSpawner は、置き場所ごとに1つ置きます。キャラの Players、敵の Enemies、弾の Bullets なら3つです。それぞれの Spawn Path に置き場所を、Auto Spawn List にそこへ出すシーンを入れます。

足すときは add_child(bullet, true) のように、2つ目に true を渡します。同じ名前の弾がいくつ並んでも、Bullet2 のような読める名前に付け直されます。付けないと @ の入った名前になり、MultiplayerSpawner がほかの画面へ出せずにエラーになります。

消すときも同じです。MultiplayerSpawner で出した物は、ホストが queue_free() すると、ほかの画面のコピーも消えます。参加者の画面で queue_free() すると、その画面のコピーだけが消えて、ホストの画面には残ります。弾の寿命が来たときも、敵を倒したときも、消すのはホストです。

MultiplayerSynchronizerで写す

敵のスクリプトは、どの画面のコピーでも動きます。ランダムに歩く敵なら、画面ごとに違う方向へ歩き出します。プレイヤーを追う敵でも、少しの差が積み重なって、画面ごとに違う場所へ行きます。

動かすのはホストだけにします。敵には誰も持ち主を入れていません。何も入れなければ、持ち主はホスト(番号 1)です。キャラと同じ is_multiplayer_authority()(この画面が、そのノードの持ち主か)で、ホスト以外の画面では何もしないようにします。

func _physics_process(delta: float) -> void:
	if not is_multiplayer_authority():
		return                          # 動かすのはホストだけ
	position.x += direction * SPEED * delta
左は全部の画面で敵を動かして画面ごとに位置がばらばら。右はホストだけが動かし、その位置をもう一方の画面へ写してそろう

ほかの画面には、ホストの値を写します。敵のシーンに MultiplayerSynchronizer を足し、下のパネルの Replication で写すプロパティを選びます。

プロパティSpawnReplicate
positionオンAlways
hpオンOn Change

Spawn をオンにすると、出した瞬間の値も一緒に届きます。ホストが位置を決めてから add_child() すれば、ほかの画面でも最初から同じ場所に出ます。Always は値が変わっていなくても送り続け、On Change は値が変わったときに送ります。動き続ける位置は Always、たまにしか変わらないHPは On Change です。

@rpcでホストに頼む

参加者が出した弾は、ほかの画面に出ません。そこで、ホストに頼みます。頼むのに使うのが @rpc です。関数の上に @rpc を付けると、その関数を、ほかの画面の同じノード(名前と親子の並びが同じ、同じパスのノード)で呼べるようになります。

@rpc("any_peer", "call_local", "reliable")
func request_shoot() -> void:
	pass                                # ホストの画面で動く。ここで弾を出す

# 撃つとき(どの画面でも同じ1行)
request_shoot.rpc_id(1)                 # 1 = ホストの画面で呼ぶ

呼ぶときは、関数名のあとに .rpc_id(1) を付けます。request_shoot() と普通に呼ぶと、@rpc を付けていても自分の画面でしか動きません。rpc_id() は番号を指定した1人へ、rpc() はつながっている全員へ呼び出します。

@rpc のかっこの中で決めることのうち、今回かかわるのは次の2つです。最後の reliable は、届くまで送り直す指定で、既定のまま書いています。

決めること既定もう一方
誰が呼べるかauthority(そのノードの持ち主だけ)any_peer(誰でも)
呼んだ本人の画面でも動かすかcall_remote(動かさない)call_local(動かす)

今回の request_shoot() は Main に置きます。Main の持ち主はホストです。既定の authority のままだと、参加者からの呼び出しはホストの側ではじかれます。エラーは、ホストの実行の Debugger に出ます。any_peer を付けて、参加者からも呼べるようにします。

ホストが自分で撃つときは、自分の画面に向けて rpc_id(1) を呼ぶことになります。call_local がないと自分宛ての呼び出しは許されず、これもエラーになります。ホストも参加者も同じ1行で撃てるように、call_local を付けます。

頼んだのが誰かは、呼ばれた側で multiplayer.get_remote_sender_id() を見ると分かります。参加者からの呼び出しなら参加者の番号、ホストが call_local で自分を呼んだなら 1 です。ホストは、この番号のキャラの上に弾を出します。

他のエンジンでいうと: @rpc は、Unity Netcode の [Rpc](旧 [ServerRpc])や、UE の Server/Multicast RPC に当たる仕組みです。Godot では、誰が呼べるかと自分でも動かすかを @rpc で決め(既定は持ち主だけ・自分では動かさない)、送り先を rpc() と rpc_id() で選びます。

Sponsored

当たりとHPの決め方

当たりを決めるのもホストだけにします。参加者の画面の弾と敵は、ホストから届いた位置で少し遅れて動いているので、画面ごとに決めるとHPが食い違います。弾の area_entered の処理の先頭で、ホスト以外は何もしないようにします。ホストの敵のHPが減り、その値が On Change でほかの画面へ写ります。

ホストの画面で�弾が当たってHPが20になり、その値が参加者の画面へ写る。0になるとホストが消し、両方の画面から消える

HPが0になったら、ホストが敵を queue_free() します。MultiplayerSpawner が、ほかの画面の敵も消します。別のPCから遊ぶと、参加者の画面で重なって見えた弾が外れることがありますが、当たりの正解はホストの画面のほうです。area_entered の使い方は Area2Dの当たり判定 にあります。

実践:2人で撃って、敵のHPを両方の画面でそろえる

2人で協力するシューティング、LANで遊ぶ見下ろしのアクション、友達と敵を倒す探索ゲーム——弾と敵が出てくるなら、作りは同じです。同じPCでウィンドウを2つ開き、画面の上を往復する3体の敵を、2人で撃って倒します。

① 置くもの

新しい2Dのプロジェクトで始めます。シーンは4つです。つなぐ部分は、同じPCで試すための最小の形にしています。IPアドレスの入力や切断の扱いは LANマルチプレイ入門 にあります。

main.tscnに3つの置き場所とMultiplayerSpawner、player・enemy・bulletにMultiplayerSynchronizer
シーンルート(名前)子
player.tscnCharacterBody2D(Player)Sprite2D(icon.svg、Scale 0.5)・Label・CollisionShape2D(CircleShape2D、Radius 32)・MultiplayerSynchronizer
enemy.tscnArea2D(Enemy)Sprite2D(icon.svg、Scale 0.5)・CollisionShape2D(RectangleShape2D、64×64)・Label・MultiplayerSynchronizer
bullet.tscnArea2D(Bullet)Sprite2D(icon.svg、Scale 0.125)・CollisionShape2D(CircleShape2D、Radius 8)・MultiplayerSynchronizer
main.tscnNode2D(Main)Lobby(HBoxContainer)とその子の Button 2つ(Host・Join)・Players・Enemies・Bullets(どれも Node2D)・MultiplayerSpawner 3つ

Player の Motion Mode は Floating(床と壁の区別をしない、見下ろし型の動き)にします。main.tscn は、FileSystem ドックの右クリックから Set as Main Scene で起動するシーンにします。

MultiplayerSynchronizer で写すのは、player と bullet が position、enemy が position と hp です。player の position は Always、bullet の position は Spawn と Always、enemy は「MultiplayerSynchronizerで写す」の表のとおりです。hp は、② の enemy.gd を付けて保存してから足します。MultiplayerSpawner の Spawn Path と Auto Spawn List には、3つの置き場所とそれぞれのシーンを組にして入れます。

最後に、エディタ上部の Debug メニューで Customize Run Instances... を開きます。Enable Multiple Instances をオンにして、数を 2 にします。Game 画面の右上のメニューで Embed Game on Next Play をオフにしておくと、2つとも普通のウィンドウで開きます。撃つのは Space(最初からある ui_accept)なので、Input Map の設定は要りません。

② 組み立て

キャラは、持ち主が動かします。

# player.gd(Player に付ける)
extends CharacterBody2D

const SPEED := 300.0

func _enter_tree() -> void:
	set_multiplayer_authority(str(name).to_int())   # キャラの持ち主は、名前の番号の人

func _ready() -> void:
	$Label.text = str(name)
	if is_multiplayer_authority():
		position = Vector2(randf_range(200, 950), 560)   # 画面の下から始める

func _physics_process(_delta: float) -> void:
	if not is_multiplayer_authority():
		return
	velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * SPEED
	move_and_slide()

敵と弾は、持ち主を入れません。持ち主はホストのままです。弾が当たった相手が敵かどうかは、take_damage() を持っているかで見分けます。

# enemy.gd(Enemy に付ける)
extends Area2D

const SPEED := 40.0

@export var hp := 30                    # MultiplayerSynchronizer で写す
var direction := 1.0

func _physics_process(delta: float) -> void:
	$Label.text = str(hp)               # どの画面でも、届いた hp を出す
	if not is_multiplayer_authority():
		return                          # 動かすのはホストだけ
	position.x += direction * SPEED * delta
	if position.x > 1050.0:
		direction = -1.0
	elif position.x < 100.0:
		direction = 1.0

func take_damage(amount: int) -> void:  # ホストの画面でだけ呼ばれる
	hp -= amount
	if hp <= 0:
		queue_free()                    # ホストが消せば、ほかの画面からも消える
# bullet.gd(Bullet に付ける)
extends Area2D

const SPEED := 600.0

func _ready() -> void:
	area_entered.connect(_on_area_entered)

func _physics_process(delta: float) -> void:
	if not is_multiplayer_authority():
		return                          # 動かすのはホストだけ
	position.y -= SPEED * delta
	if position.y < -50.0:
		queue_free()                    # 画面の上へ出たら、ホストが消す

func _on_area_entered(area: Area2D) -> void:
	if not is_multiplayer_authority():
		return                          # 当たりを決めるのもホストだけ
	if area.has_method("take_damage"):  # 敵だけが持っている関数
		area.take_damage(10)
		queue_free()

敵と弾のスクリプトは、どの画面でも動きます。ただ、先頭の is_multiplayer_authority() を通り抜けるのはホストの画面だけです。参加者の画面は、hp を表示したところで戻ります。

同じ敵のスクリプトでも、ホストの画面は動かして当たりまで進み、参加者の画面は持ち主の確認で戻って表示だけする

Main は、つなぐ・出す・頼まれて撃つ、の3つを受け持ちます。

# main.gd(Main に付ける)
extends Node2D

const PORT := 7000
const PLAYER_SCENE := preload("res://player.tscn")
const ENEMY_SCENE := preload("res://enemy.tscn")
const BULLET_SCENE := preload("res://bullet.tscn")

func _ready() -> void:
	$Lobby/Host.pressed.connect(_on_host_pressed)
	$Lobby/Join.pressed.connect(_on_join_pressed)
	multiplayer.peer_connected.connect(_on_peer_connected)
	multiplayer.connected_to_server.connect($Lobby.hide)  # 参加者は、つながったらロビーを閉じる

func _on_host_pressed() -> void:
	var peer := ENetMultiplayerPeer.new()
	peer.create_server(PORT)
	multiplayer.multiplayer_peer = peer
	$Lobby.hide()
	_add_player(1)
	for i in 3:                                          # 敵もホストが出す
		var enemy := ENEMY_SCENE.instantiate() as Node2D
		enemy.position = Vector2(250 + i * 300, 100 + i * 100)
		$Enemies.add_child(enemy, true)

func _on_join_pressed() -> void:
	var peer := ENetMultiplayerPeer.new()
	peer.create_client("127.0.0.1", PORT)
	multiplayer.multiplayer_peer = peer

func _on_peer_connected(id: int) -> void:
	if multiplayer.is_server():
		_add_player(id)

func _add_player(id: int) -> void:
	var player := PLAYER_SCENE.instantiate()
	player.name = str(id)
	$Players.add_child(player, true)

func _unhandled_input(event: InputEvent) -> void:       # ほかで使われなかった入力が届く
	if event.is_action_pressed("ui_accept") and not $Lobby.visible:
		request_shoot.rpc_id(1)                          # ホストに「撃ちたい」と頼む

@rpc("any_peer", "call_local", "reliable")
func request_shoot() -> void:                            # ホストの画面で動く
	var id := multiplayer.get_remote_sender_id()         # 頼んだ人の番号
	var shooter := $Players.get_node_or_null(str(id)) as Node2D
	if shooter == null:
		return
	var bullet := BULLET_SCENE.instantiate() as Node2D
	bullet.position = shooter.position + Vector2(0, -48) # 頼んだ人のキャラの上
	$Bullets.add_child(bullet, true)                     # ホストが足せば、全員の画面に出る

③ 動かして確かめる

左のホストのウィンドウと右の参加者のウィンドウで、参加者が撃った弾が同じ位置を飛び、当たった敵の数字が両方とも20になっている

実行するとウィンドウが2つ開きます。

まず、片方の画面で Host を押します。画面の上に「30」を付けた敵が3体出て、ゆっくり左右に往復し始めます。もう片方の画面で Join を押すと、こちらにも同じ3体が出て、ホストの画面と同じように往復します。画面ごとに敵の位置がずれていくなら、敵の _physics_process() の先頭の行か、position の Always を見ます。

次に、両方の画面にキャラが2体そろってから、参加者の画面をクリックして Space を押します。キャラの上から弾が出て、両方の画面で上へ飛びます。試しに main.gd の @rpc から "any_peer", を消して実行し直してください。ホストの画面で撃つ弾は出ますが、参加者の画面で押しても弾は出ず、ホストの Debugger に「RPC 'request_shoot' is not allowed」で始まるエラーが出ます。参加者からの頼みを受け付けるのが any_peer だと、この1か所で分かります。確かめたら戻します。

仕上げに、矢印キーで敵の真下へ行き、1発ずつ撃ちます。当てるたびに両方の画面で敵の数字が 30、20、10 と減り、3発目で両方の画面から消えます。参加者が撃った弾でも、ホストが撃った弾でも同じです。

最後に、途中から入る場合を見ます。いったん両方を閉じて実行し直し、ホストだけで敵に1発当てて「20」にしてから、もう片方で Join を押します。参加者の画面にも、最初から「20」が出ます。hp の Spawn をオンにしているので、出した瞬間の値として、いまのHPが届くからです。

ポイントは2つです。

  • 参加者は弾を作らない: 参加者の画面が弾のために実行しているのは、request_shoot.rpc_id(1) の1行だけです。弾を出して動かし、当たりを決めるのはホストで、参加者の画面はそれを写しています
  • キャラと弾で、持ち主が逆: キャラは持ち主が動かし、弾と敵はホストが動かします。どちらも is_multiplayer_authority() で守るので、違うのは持ち主を入れるかどうかだけです

敵に巡回や追跡をさせるときも、AIを動かすのはホストの画面だけです。AIの組み立て方は ステートマシン入門 にあります。

うまくいかないときは

症状原因直し方
参加者の画面に、動かない弾が残る。弾を出すときのエラーが出るrequest_shoot.rpc() で全員に頼んでいる。参加者の画面でも、その画面だけの弾を足しているrpc_id(1) でホストにだけ頼む
エディタで置いた敵が、倒しても参加者の画面に残るMultiplayerSpawner が扱うのは、実行中にホストが足した子だけ敵もホストがコードで出す
敵や弾が、どの画面でも止まったままキャラのスクリプトから set_multiplayer_authority() の行を写した。名前から取った番号が持ち主になり、どの画面も持ち主でなくなる敵と弾では持ち主を入れない
参加者の画面だけ、敵の数字が30のまま数字の書き換えを take_damage() の中に書いている。take_damage() はホストの画面でしか動かない数字は、どの画面でも動く _physics_process() の先頭で書き換える

おまけ:先に知っておくと良いこと

  • 弾のプールは、ネットワークでは手間が増える: オブジェクトプール で弾を使い回すと、使っているかどうかも写す必要が出ます。まずは出して消す形で作ります
  • 連打の間隔もホストで数える: 撃てる間隔を決めるなら、ホストの request_shoot() の中で、頼んだ人ごとに前に撃った時刻を覚えておきます。弾を出すのがホストなので、数えるのもホストです
Sponsored

まとめ

  • キャラは持ち主、弾と敵はホスト: 弾と敵は、ホストが出して、動かして、当たりを決める。ほかの画面は写すだけ
  • 出す: ホストが add_child(x, true) した物を、MultiplayerSpawner が全員の画面に出す。消すのもホストの queue_free()
  • 写す: MultiplayerSynchronizer で、position は Spawn と Always、hp は Spawn と On Change
  • 頼む: 参加者は rpc_id(1) でホストの関数を呼ぶ。any_peer で参加者から、call_local でホスト自身からも呼べる
  • 当たりもホスト: 当たりとHPの計算はホストだけ。0になったらホストが消せば、全員の画面から消える

2人の画面で同じ敵が同じ数字で倒れたら、協力プレイの土台はできています。自分のゲームで、ホストに決めてもらうべき物はほかに何があるでしょうか。

Godot このセクションのノート104