One, foreword

Godot has developed a Multiplayer game using the High Level Multiplayer API (part 1).

Main content: LAN multiplayer game development code brief analysis and development summary

Reading time: 12 minutes Permanent link: liuqingwen me / 2020/07/23 /… Series homepage: liuqingwen. Me/introductio…

Second, the body

I have uploaded the source of this Demo to Github. If you are interested, you can experience the rough degree of the game here: gotm. IO /spkingr/bom… Enter the game and click Host Lobby. After creating the server, invite your friends to join you on a “Crazy Bomb” tour. ** Important note: ** All the graphics of this game are drawn by myself, the first drawing is inevitably garbage to drop the cinder, in addition, the background music is also I spent 5 minutes to fix, silently bear the audio-visual torture brought by the novice! :joy:

Part of the game code brief analysis

First, the most important and central part of a networked game is the code that handles the game’s LAN connection. This is a Singleton script, also known as AutoLoad in Godot. The code does not need to be tied to a node. The gamestate. gd singleton script that handles network connections needs to be added and enabled in the project Settings:

GameState code

Serve straight:

extends Node

# Custom signal
signal player_list_update(players, colors)     # Update information when new players join
signal player_color_update(id, color)          # Player color updates
signal player_ready_status_update(id, isReady) # Player prepare or unprepare
signal player_disconnected(id)                 # Disconnect signal
signal connection_succeeded()                  The connection is successful
signal game_ended(why)                         # Game over signal
signal game_ready(isReady)                     # Are gamers ready
signal game_loaded()                           # The game is about to start after loading

# Define ports, maximum number of connections, game scenes to load, and colors for players to choose
const PORT := 34567
const MAX_PLAYERS := 4
const GAME_SCENE := 'res://World/Game.tscn'
const COLORS := [Color('#B0BEC5'), Color('#8D6E63'), Color('#FFAB91'), ...] # omit

Basic attributes: Network ID, name, color, other player information, etc
var myId := -1
var myName := ' '
var myColor := Color.white
var otherPlayerNames := {}   # id - the name of a dictionary
var otherPlayerColors := {}  # id - color dictionary
var isGameStarted := false

# Ready player and currently available colors, only used in the main scene (actual server)
master var readyPlayers := []
master var availableColors := []

The Godot High-level Multiplayer API has shipped all 5 signals
func _ready() -> void:
    self.get_tree().connect('network_peer_connected', self, '_onNewPlayerConnected')
    self.get_tree().connect('network_peer_disconnected', self, '_onPlayerDisconnected')
    self.get_tree().connect('server_disconnected', self, '_onServerDisconnected')
    self.get_tree().connect('connected_to_server', self, '_onConnectionSuccess')
    self.get_tree().connect('connection_failed', self, '_onConnectionFail')
Copy the code

The above code is a basic definition that was discussed in the previous article: all code is common. So the client code, each player is not only to preserve their own information, and record information that is relevant to other players in the code performance for variable otherPlayerNames/otherPlayerColors necessity. In addition, the five Godot signals in the _ready() method are generally required to handle network connection-related events. For details, see the official documentation: Managing Connections. We study the locations, invocation methods and functions of these signals respectively:

Every time a new client connects to the server, all other player ids will call this method
# Regardless of whether the current node is a server or a client: I received a player connection notification from that ID
func _onNewPlayerConnected(id : int) -> void:
    if isGameStarted:
        return

    # use rpc_id to send your information to the other party remotely
    self.rpc_id(id.'_addMyNameToList', myName, myColor)

    # Only [server] handles game preparation events and color assignments
    if self.get_tree().is_network_server():
        self.emit_signal('game_ready', false)

        var color := _getRandomColor()
        self.rpc('_updateColor'.id, color)

Every time the client ID disconnects, all other players call this method
If the game has started, signal player_disconnected
Otherwise, simply remove information about the player with that ID (readiness, etc.).
func _onPlayerDisconnected(id : int) -> void:
    if isGameStarted:
        self.emit_signal('player_disconnected'.id)
    else:
        _removeDisconnectedPlayer(id)

The current client link is successful, only the [client] call
# indicates that the current local player has entered the game hall and is ready to play
func _onConnectionSuccess() -> void:
    self.emit_signal('connection_succeeded')

# Server disconnected, only [client] call
Exit the game, clear the network connection and other relevant information
func _onServerDisconnected() -> void:
    self.emit_signal('game_ended'.'Server disconnected.')

# client link failed, only [client] call
func _onConnectionFail() -> void:
    self.emit_signal('game_ended'.'Connection failed.')

# remote method that handles calls from other players and adds information about other players to otherPlayerNames
Note that this method is actually called (sent) by another player, or that you receive messages from other players through this method
remote func _addMyNameToList(playerName : String, playerColor : Color) -> void:
    var id = self.get_tree().get_rpc_sender_id()
    otherPlayerNames[id] = playerName
    if ! otherPlayerColors.has(id):
        otherPlayerColors[id] = playerColor
    self.emit_signal('player_list_update', otherPlayerNames, otherPlayerColors)

# Update the color, the color is randomly selected, only determined by the [server] allocation, to ensure that the color is not repeated
# remotesync indicates that this method runs in every player, and is called uniformly by the server
remotesync func _updateColor(id : int, color : Color) -> void:
    if id == myId:
        myColor = color
    else:
        otherPlayerColors[id] = color

    self.emit_signal('player_color_update'.id, color)

# omit some code...
Copy the code

One interesting Bug I encountered while writing this code was that network_peer_connected was the default color for new players added! I didn’t define a separate player_COLOR_update color signal, just update the player’s name and color in the _addMyNameToList method. Why is the name correct but the color wrong? The reason is simple: ** While this method will send the player’s own color to other player scenes, if it is a new player, the color will probably not have been assigned by the server execution, so it will default to white. As I said, the solution is to add an update color signal to ensure that each player receives the correct color values from other players.

Before networking, we first need to create a server, or connect to a known server as a client.

# create server, return a result
An error is returned if an IP address is occupied
func hostGame(playerName: String) -> bool:
    myName = playerName
    otherPlayerNames.clear()
    otherPlayerColors.clear()
    availableColors = COLORS.duplicate()
    readyPlayers.clear()

    var host := NetworkedMultiplayerENet.new()
    var error := host.create_server(PORT, MAX_PLAYERS)
    iferror ! = OK:return false

    self.get_tree().network_peer = host
    self.get_tree().refuse_new_network_connections = false

    myId = self.get_tree().get_network_unique_id() # id = 1 is the server
    myColor = _getRandomColor()
    return true

# create client, join the game, need to specify IP address
func joinGame(address: String, playerName: String) -> bool:
    myName = playerName
    otherPlayerNames.clear()
    otherPlayerColors.clear()
    readyPlayers.clear()

    var host := NetworkedMultiplayerENet.new()
    var error := host.create_client(address, PORT)
    iferror ! = OK:return false

    self.get_tree().network_peer = host

    myId = self.get_tree().get_network_unique_id()
    return true

Reset the network to NULL and disconnect all connections
func resetNetwork() -> void:
    isGameStarted = false
    otherPlayerNames.clear()
    otherPlayerColors.clear()
    self.get_tree().network_peer = null
Copy the code

This part of the code is very simple and is highlighted in the official documentation. Now that you have the server and client, you’re ready to start the game, and this part of the code takes a lot of twists and turns in order to keep players connected to the Internet playing at the same time:

Client call, ready or unready
func readyGame(isReady : bool) -> void:
    self.rpc('_readyGame', isReady)

# Remote way to send if the player is ready
remote func _readyGame(isReady : bool) -> void:
    # one player sends it, all other players receive it, updating that player's readiness
    var id := self.get_tree().get_rpc_sender_id()
    self.emit_signal('player_ready_status_update'.id, isReady)

    # This part of the code is only [server] side processing, depending on whether the player is [all ready] to decide whether to start the game
    if self.get_tree().is_network_server():
        if isReady:
            readyPlayers.append(id)
            self.emit_signal('game_ready', readyPlayers.size() == otherPlayerNames.size())
        else:
            readyPlayers.erase(id)
            self.emit_signal('game_ready', false)

# [server] side call, the owner clicks the start game button
# Officially opened: Twists and turns games start series!
func startGame() -> void:
    self.get_tree().refuse_new_network_connections = true
    readyPlayers.clear()
    self.rpc('_prestartGame')

# 1. Start the game step 1: instantiate the game scene and pause it, notifying the server to wait for other players
remotesync func _prestartGame() -> void:
    isGameStarted = true
    # Instantiate the game battlefield and pause, wait
    var game : Node2D = load(GAME_SCENE).instance()
    game.name = 'Game'
    game.set_network_master(1)
    self.get_parent().add_child(game)
    self.get_tree().paused = true

    if self.get_tree().is_network_server():
        Run locally on the server
        _postStartGame(myId)
    else:
        # 1 represents the server ID and sends a message to the server that it is ready to start
        self.rpc_id(1.'_postStartGame', myId)


# 2. Start the game Step 2: Wait for all players to load and instantiate the game scene
We know from the above call that this method must only run on the server side
remote func _postStartGame(id : int) -> void:
    readyPlayers.append(id)
    # Make sure all players are ready, including yourself
    if readyPlayers.size() == otherPlayerNames.size() + 1:
        self.rpc('_startGame')

# 3. Start the game Step 3: All enter the game and go
remotesync func _startGame() -> void:
    readyPlayers.clear()
    self.emit_signal('game_loaded')
Copy the code

How the code works is explained in the comments, if you have any questions please leave me a message, I will try to answer them. πŸ™‚

Second, the main Game scene code

The code above shows that the first node instantiated is the Game’s main scene: game.gd. When the game starts, all players will be added to the main scene (remember the last post? A master node player, all other slave nodes), of course, also need to deal with other events: player event processing, send relevant messages, player death and results, enemy generation, etc., these content is not complicated, interested friends can look at the source code, here I explain the key parts:

# initialization
func _ready() -> void:
    if GameConfig.isSoundOn:
        _audioPlayer.play()

    _resultPopup.showPopup('Waiting for other players... '.'Waiting', true, _resultPopup.BUTTON_BACK_BIT + _resultPopup.BUTTON_STAY_BIT)

    GameState.connect('game_loaded', self, '_onGameLoaded')
    GameState.connect('game_ended', self, '_onGameEnded')
    GameState.connect('player_disconnected', self, '_onPlayerQuit')

    _setDifficulties()
    _addPlayers()

    GameConfig.sendMessage(GameConfig.MessageType.System, GameState.myId, 'enters the game! ')
    GameConfig.rpc('sendMessage', GameConfig.MessageType.System, GameState.myId, 'enters the game! ')

# Add player, only one master object, all other puppet objects
Only the master node can add related events. Set the corresponding master_id
# The starting position of the player, determined by the size of the player id, ensures uniformity
func _addPlayers() -> void:
    var positions := [GameState.myId] + GameState.otherPlayerNames.keys()
    positions.sort()
    var player := PlayerNode.instance()
    player.connect('lay_bomb', self, '_on_Player_lay_bomb')
    player.connect('dead', self, '_on_Player_dead')
    player.connect('damaged', self, '_on_Player_damaged')
    player.connect('collect_item', self, '_on_Player_collect_item')
    player.name = str(GameState.myId)
    player.playerId = GameState.myId
    player.playerName = GameState.myName
    player.playerColor = GameState.myColor
    player.global_position = _playerPositionNodes[positions.find(GameState.myId)].position
    player.set_network_master(GameState.myId)
    _playersContainer.add_child(player)
    _allPlayers.append(GameState.myId)

    for id in GameState.otherPlayerNames:
        player = PlayerNode.instance()
        player.name = str(id)
        player.playerId = id
        player.playerName = str(GameState.otherPlayerNames[id])
        player.playerColor = GameState.otherPlayerColors[id]
        player.global_position = _playerPositionNodes[positions.find(id)].position
        player.set_network_master(id)
        _playersContainer.add_child(player)
        _allPlayers.append(id)

    for node in _playerPositionNodes:
        node.queue_free()
Copy the code

Set_network_master (id) = gamestate.myid = gamestate.myid = gamestate.myid = gamestate.myid = gamestate.myid = gamestate.myid = gamestate.myid = gamestate.myid Players’ names are also their ids, ensuring that all player nodes in each player are consistent.

Player code

The Player code player.gd is not that complicated, but there are a few key points to explain:

func _unhandled_input(event: InputEvent) -> void:
    # This part of the code does not distinguish between master and non-master nodes
    Master node, slave node display player name
    if event.is_action_pressed('show_name'):
        _labelName.show()
    elif event.is_action_released('show_name'):
        _labelName.hide()

    if ! self.is_network_master():
        return
    # This code can only run in the host node: place the bomb
    if _isStuning || _isDead:
        return
    if event.is_action_pressed('lay_bomb'):
        _layBomb()

func _physics_process(delta):
    This can only be run on the host node
    if ! self.is_network_master():
        return
    if _isStuning || _isDead:
        return
    self.move_and_slide(_velocity)

    # Update the location of the slave node in other scenarios. Here, use rpc_unreliable to send packets
    self.rpc_unreliable('_updatePosition', self.position)

# The following method can only run on the master node, and the master node sends the necessary messages to the corresponding slave node within the code
master func bomb(byKiller : int, damage : int) -> void:
    damage(damage, Vector2.ZERO, byKiller)

master func damage(amount : float, direction : Vector2 = Vector2.ZERO, byId : int = -1) -> void:
    #... omit

master func collect(itemIndex : int) -> void:
    #... omit
Copy the code

In general, virtual methods like _process or _Physics_process try to ensure that only the master node runs the logic, and then the master node updates the behavior of the corresponding slave node in other player scenes, such as player orientation, current animation, current position, etc. Conversely, because the operation of these methods will vary according to machine performance, if synchronization is not guaranteed, then online games will become stand-alone games, how to ensure efficient synchronization of online games is indeed a difficult problem.

The above code is basically the core part of the game, the other part is relatively simple, I hope that through these codes can let you avoid many pits, quickly develop their favorite game, hey hey.

Other sample code

Enemy.gd is a monster scene script, because the _physics_process method is a bit complicated. In order to update and synchronize puppet slave nodes, I add the _process method. Update slave node locations, graphics, and animations for monsters in other scenes:

`func _process(delta: float) -> void:
    if self.get_tree().network_peer == null || ! self.is_network_master():
        return
    if _isDead || _isPaused:
        return
    self.rpc_unreliable('_puppetSet', self.position, _sprite.flip_h, _animationPlayer.current_animation)
Copy the code

There is another one I added later, the implementation of the server kick function, very simple, let the service send a message to the kicked player ID to notify it to call the method of quitting the game:

Run on the server
func _onPlayerBeKickedOut(id : int) -> void:
    self.rpc_id(id.'_kickedOut')

Run on the client
remote func _kickedOut() -> void:
    #... omit
    self.get_tree().network_peer = null
Copy the code

Other code parts, including bomb explosion, send messages, display game results, dropped items and other processing I will not explain, I believe that we do games have their own implementation, if not clear, can refer to my source code. πŸ™‚

Summary of game development

Back and forth, game development took me a long time. Although the game is simple, pit is really many, limited to memory and space, here sum up a few typical problems that trouble me for a long time.

1. The names must be the same

When testing on the computer, I found that occasionally bombs, monsters, explosion effects and other graphics would not disappear in the “mirror end”, just like the Bug in the picture:

This worked fine on the computer and popped up occasionally, but after Posting to the web the Bug was triggered quite frequently. At first I thought there was a lag in the game that caused the method call to be out of sync and therefore invalid. Changing the order of method calls didn’t solve the problem, until I found out from the console error log:

E 0:00:11. 206 _process_get_node: Failed to get cached path from the RPC: Game/Enemies/Enemy123456.

This error indicates a problem: the node names corresponding to the Master and Puppet nodes (e.g. the path path in Godot) do not match at all! Knowing the problem, the solution is simple, for any generated object, need to agree on a unique name, and then at each end of the production can be, such as generated items, bombs, monsters, etc., the name count, to ensure that the unique and uniform. For example, the monster code generated in the game is as follows:

# Spawn enemies
func _spawnEnemy() -> void:
    #...
    # define an integer field that increments each enemy name by 1
    _enemyNameIndex += 1
    var pos := _tileMap.map_to_world(tile) + _tileMap.cell_size / 2
    var name := 'Enemy' + str(_enemyNameIndex)
    Send the name as data to other clients to ensure the same name [consistent]
    self.rpc('_addEnemy', pos, name)

# Remote add enemy method
remotesync func _addEnemy(pos : Vector2, name : String) -> void:
    var enemy = enemyScene.instance()
    enemy.name = name
    enemy.set_network_master(1) Take the server side object as master
    enemy.global_position = pos
    _enemiesContainer.add_child(enemy)
Copy the code

2. Don’t pass complex data

This question also puzzled me for a while. Generate a simple object in the main scene, then send information about that object to other Puppet scenes, but get empty data in other scenes! I wonder if it’s because the data passed in the remote method is a complex data type? I changed the code to pass the object path string instead:

# change the code before:
self.rpc('_addItem', GameState.myId, item)
remotesync func _addItem(id : int, item : GameConfig.ItemData) -> void:
    var power : Node = load(item.data).instance()
    power.set_network_master(id)
    self.add_child(power)

# Modified code:
self.rpc('_addItem', GameState.myId, item.data)
remotesync func _addItem(id : int, data : String) -> void:
    var power : Node = load(data).instance()
    power.set_network_master(id)
    self.add_child(power)
Copy the code

Compare the code before and after the modification, the later code is normal. However, in the previous code, the complex data type of ItemData was passed remotely. After changing to String, this problem was solved. As to whether it is caused by passing complex data types, I have not done the test for the time being. Try to keep simple data types, which is also beneficial to improve the network speed. :smiley:

3. Ensure that the server is connected

There’s one more minor issue that doesn’t affect the game, but it still makes me feel bad:

E 0:00:01.821 get_network_unique_id: No network peer is assigned. Unable to get unique network ID.

The main cause is an accidental network disconnection, causing an error after calling self.is_network_master().

func _physics_process(delta: float) -> void:
    if self.get_tree().network_peer == null || ! self.get_tree().is_network_server():
        return
    #...
Copy the code

4. Synchronize important data

The server and client share a set of code, so some data initialization can be either sent by the server or initialized individually. For complex data, it is obviously not necessary to occupy the network resources of remote calls, such as map-related data, so please do not forget to perform necessary initialization to ensure data synchronization and sharing:

func _ready() -> void:
    # this will run on the server side and the client side to keep _brokenTiles synchronized
    _navigation = self.get_parent()
    for tile in self.get_used_cells():
        if self.get_cellv(tile) == GameConfig.GRASS_TILE_ID:
            _brokenTiles.append(tile)
Copy the code

5. Other small questions

If get_tree().refuse_new_network_connections = false is set to the server, the client can still join. However, the newly added client does not see any ID information on other hosts, including the server. So you won’t normally participate in the game, so it’s a minor and harmless BUG.

Maybe it’s a Godot BUG? !

Third, summary

It is necessary to summarize my personal development experience here:

  1. _ready/_process/_inputSuch system method calls should pay special attention to whether to run inmasterThe master node in the
  2. Many events, such as the end of a Timer, also require special attention to separate master and slave nodes from each other in the methods that are linked using the editor
  3. Some public methods and properties should be used carefully when external calls are mademaster/puppetKeyword Distinguish master/slave running scenarios
  4. puppetIn most cases it’s the sameremoteKeyword, because all your calls take place inmaster δΈ­
  5. master/puppetCompared with theremoteOne application scenario is if MasterA triggers or calls a method in PuppetB, then usemaster/puppetbetter
  6. Just as all new items are added using remote calls, RPC is also required to remove items, such as adding monsters or changing a Tile on a map

If you have any questions, please add them to my wechat or QQ to discuss. The Demo and relevant codes of this piece have been uploaded to Github at github.com/spkingr/God… , I will continue to update, the original is not easy, I hope you like! πŸ™‚

My blog address: liuqingwen. Me, my blog will be synchronized to tencent cloud + community, invite everyone to come together: cloud.tencent.com/developer/s… , welcome to follow my wechat official account: