|
| 1 | +.. _doc_networked_player_movement: |
| 2 | + |
| 3 | +Networked player movement |
| 4 | +========================= |
| 5 | + |
| 6 | +Most modern online multiplayer games operate under what is called a server authoritative client-server model. |
| 7 | +In this model, clients connect directly to a game server over a socket connection and pass messages back and forth. |
| 8 | +Being server-authoritative means that the clients are "thin" or "dumb" and make no decisions about the state of the game |
| 9 | +world themselves. Instead, they merely pass player input to the server and handle the display of whatever the server tells it |
| 10 | +that the current state of the game world is. In other words, the server is the arbiter of the game world that all clients must abide by. |
| 11 | + |
| 12 | +Normally, this isn't that big of a deal. A client informs the server of its intent, such as :code:`Player swings sword` and the server |
| 13 | +receives this intent, modifies its local game world state, and then broadcasts the relevant new state to all clients. When a client receives |
| 14 | +this new state, it updates its own local copy of the game world state and renders it. This asynchronous nature of communication between the |
| 15 | +server and clients is what allows players spanning geographical regions to interact with a shared game world in real time. There is one facet |
| 16 | +of online multiplayer games that doesn't work so seamlessly in a sever authoritative model. That being player movement. |
| 17 | + |
| 18 | +If you consider how player movement would work if implemented using the appraoch above, it would go something like this: |
| 19 | + |
| 20 | +1. Player sends intent to move left |
| 21 | +2. Server receives players intent |
| 22 | +3. Server moves player left in its local game world |
| 23 | +4. Server broadcasts player's new transform to all clients |
| 24 | +5. Clients update individual local game worlds with the player's new transform and renders it |
| 25 | + |
| 26 | + |
| 27 | +At a glance this may seem fine, however remember that in a networked environment, we have latency to deal with. Even with a good network connection, |
| 28 | +transfering data over the internet always has at least *some* inherent latency. For instance, it's not unusual for the time between the player sending |
| 29 | +its intent to the server and the time at which a client receives the new authoritative server state to exceed 200ms. If you consider how this would look |
| 30 | +from a player's perspective, you'll quickly realize how choppy the user experience can be. Fortunately, people have been making online multiplayer games |
| 31 | +for a relatively long time now, and techniques have been developed to combat these shortcomings, while still allowing the server to remain authoritative. |
| 32 | + |
| 33 | +Client-side prediction |
| 34 | +---------------------- |
| 35 | + |
| 36 | +Client-side prediction is a rather simple technique to relieve the choppiness of sever authoritative player movement from the player performing the movement. |
| 37 | +Alone, it's not very useful, but when combined with server reconciliation (as described below) it allows the player to move fluidly while still leaving arbitration |
| 38 | +in the hands of the server. To be honest, client-side predicition here is a bit of a misnomer. A more accurate term would be "client-side assumption," as we will see. |
| 39 | + |
| 40 | +Essentially, client-side prediciton boils down to performing the intended movement on the client, assuming that no shenanigans like cheating occur and the the network |
| 41 | +connection is stable. Let's take a look at how we can implement basic client-side prediciton for a networked KinematicBody2D. We will expand upon this example as we |
| 42 | +introduce the other components of networked player movement. |
| 43 | + |
| 44 | +:: |
| 45 | + |
| 46 | + # networked_player.gd |
| 47 | + extends KinematicBody2D |
| 48 | + class_name NetworkedPlayer |
| 49 | + |
| 50 | + export var speed = 3.0 |
| 51 | + |
| 52 | + func move(direction): |
| 53 | + if direction.length() == 0: |
| 54 | + return |
| 55 | + |
| 56 | + rpc_unreliable_id(NetworkedMultiplayerPeer.TARGET_PEER_SERVER, "_move_server", direction) |
| 57 | + move_and_slide(direction*speed) |
| 58 | + |
| 59 | + puppet func _move_server(direction): |
| 60 | + if not multiplayer.is_network_server(): |
| 61 | + return |
| 62 | + move_and_slide(direction*speed) |
| 63 | + rpc_unreliable("_set_peer_position", position) |
| 64 | + |
| 65 | + remote func _set_peer_position(pos): |
| 66 | + if multiplayer.get_rpc_sender_id() == NetworkedMultiplayerPeer.TARGET_PEER_SERVER and not multiplayer.is_network_master(): |
| 67 | + position = pos |
| 68 | + |
| 69 | + |
| 70 | +In this example, we send the move intent (direction) to the server and then immediately move the character locally how we assume the server will do so. |
| 71 | +When the server eventually receives our intent to move, it will move the character in the authoritative game world as well and then tell the clients what |
| 72 | +the new position is. All of the clients except the network master (the one controlling the player) will update their local positions to match. |
| 73 | + |
| 74 | +Server reconciliation |
| 75 | +--------------------- |
| 76 | +Client side prediciton gets us part of the way there for our player's character, however it only solves the issue of choppiness introduced by latency. The |
| 77 | +server authoritative state is never applied to the player character of the controlling player, only its peers. We could naively solve this by simple overriding |
| 78 | +the position of the controlling player's character as well, just like we already do for the peers' copyies of the character however is we again consider latency, |
| 79 | +we realize that this might not be the best idea. Since introducing client-side predicition, we now move the player character locally every time :code:`move` is called, |
| 80 | +which is likely every frame or every physics frame. This means that by the time the authoritative position comes back from the server, the local character would have |
| 81 | +already moved even further. Resetting the local position to the server authoritative state now would result in the character appearing to teleport backwards, resulting |
| 82 | +in a phenomenon known as "rubber banding." It turns out, solving this problem isn't so simple and requires some substantial changes to our code. The teqnique we use here |
| 83 | +is known as server reconciliation. |
| 84 | + |
| 85 | +Essentially, before we send out input off to the server, we store it in an input buffer along with an sequence number. We also buffer the input on the server as well and |
| 86 | +introduce a polling frequency in order to reduce bandwidth. This is an optimization that is mostly orthogonal to the topic at hand, but it's a good practice none-the-less. |
| 87 | +Whenever the server processes an input from the buffer, it broadcasts the resulting state to all clients. Clients store the last known state in a member varibale and ever |
| 88 | +physics frame on the controlling client, we reset the character's local position to that of the last known server state and then we loop through all of the inputs in the |
| 89 | +input buffer and for each one whose sequence number is greater than that of the last known server state, we reapply it locally. If there was no cheating and the network |
| 90 | +connection was stable, the resulting posiiton should be the same as it was before, however if there was some cheating or dropped messages, then the resulting state will be |
| 91 | +synchroized to what the server thinks + any predicted movement since. In code, this would look something like this: |
| 92 | + |
| 93 | +:: |
| 94 | + |
| 95 | + # networked_player.gd |
| 96 | + extends KinematicBody2D |
| 97 | + class_name NetworkedPlayer |
| 98 | + |
| 99 | + export var speed = 3.0 |
| 100 | + export server_tick_interval = 100 |
| 101 | + |
| 102 | + var _last_server_state = {} |
| 103 | + var _input_buff = [] |
| 104 | + var _accum = 0.0 |
| 105 | + var seq = 0 |
| 106 | + |
| 107 | + func move(direction): |
| 108 | + if direction.length() == 0: |
| 109 | + return |
| 110 | + var cmd = { |
| 111 | + "d": direction, |
| 112 | + "seq": seq, |
| 113 | + } |
| 114 | + rpc_unreliable_id(NetworkedMultiplayerPeer.TARGET_PEER_SERVER, "_buffer_input", cmd) |
| 115 | + _buffer_input(cmd) |
| 116 | + move_and_slide(direction*speed) |
| 117 | + seq+=1 |
| 118 | + |
| 119 | + func _physics_process(delta): |
| 120 | + # If this player instance is the server, then it |
| 121 | + # is the source of truth. It should process the |
| 122 | + # buffered input and replicate it back to the client. |
| 123 | + if multiplayer.is_network_server(): |
| 124 | + var curr = OS.get_system_time_msecs() |
| 125 | + if (curr - _accum) >= server_tick_interval: |
| 126 | + _process_server_input(delta) |
| 127 | + _accum = curr |
| 128 | + |
| 129 | + # If this is the entity who sent the input, then reconcile |
| 130 | + # with whatever the last known server state is. This might |
| 131 | + # contradict what was sent if the sent input was invalid or |
| 132 | + # deemed incorrect by the server. |
| 133 | + elif is_network_master(): |
| 134 | + _reconcile(delta) |
| 135 | + |
| 136 | + # Server reconciliation. Directly set the current position to that |
| 137 | + # of the last known server state, and then reapply all inputs since then. |
| 138 | + # This ensures client consistency with the server state. |
| 139 | + # @see https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html |
| 140 | + func _reconcile(delta): |
| 141 | + var last_state = _last_server_state |
| 142 | + if last_state: |
| 143 | + # set the current position to the last known server state |
| 144 | + position = last_state.p |
| 145 | + |
| 146 | + # reapply any input since the last known server state. |
| 147 | + var del = [] |
| 148 | + while not _input_buff.empty(): |
| 149 | + var cmd = _input_buff.pop_front() |
| 150 | + if cmd.seq > last_state.seq: |
| 151 | + move_and_slide(cmd.d * speed) |
| 152 | + |
| 153 | + func _process_server_state(delta): |
| 154 | + if _input_buff.empty(): |
| 155 | + return |
| 156 | + var last_seq = 0 |
| 157 | + while not _input_buff.empty(): |
| 158 | + var cmd = _input_buff.pop_front() |
| 159 | + last_seq = cmd.seq |
| 160 | + move_and_slide(cmd.d * speed) |
| 161 | + rpc_unreliable("_append_server_state, {"p": position, "seq": last_seq}) |
| 162 | + |
| 163 | + remote func _append_server_state(state): |
| 164 | + if multiplayer.get_rpc_sender_id() == NetworkedMultiplayerPeer.TARGET_PEER_SERVER: |
| 165 | + _last_server_state = state |
| 166 | + |
| 167 | + # Keep a buffer of inputs. This is executed on the server and the client |
| 168 | + # that sent the inputs. The server will use this buffer for processing, |
| 169 | + # while the client will use it for server reconciliation. |
| 170 | + puppet func _buffer_input(input : Dictionary) -> void: |
| 171 | + _input_buff.append(input) |
| 172 | + |
| 173 | +.. note:: For more information on client side prediction and server reconciliation, check out `the wonderful article by Gabriel Gambetta <https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html>`__. |
| 174 | + |
| 175 | +Entity interpolation |
| 176 | +-------------------- |
| 177 | +The final piece to the networked player character puzzle is *entity interpolation*. Client-side prediction and server reconciliation solve the problem of networked movement for |
| 178 | +the character the player is controlling, but they don't help with movement from *other* players. Luckily entity inerpolation can solve this for us rather easily. If we consider that |
| 179 | +we are actually receiving character state from the *past*, it might become apparent that all we need to do is lerp to the last known server position and call it a day. This, of course means that peers will always appear |
| 180 | +slightly behind real time ("slightly" here depends on latency, but typically no more than around 250ms unless your connection is unstable), but in the case of peer movement, this is usually find. In the end, in most games, |
| 181 | +the exact position of a peer player at any given time isn't that important. A coarse estimation is usually enough. We can easily add entity inerpolation to our example code. |
| 182 | + |
| 183 | +:: |
| 184 | + |
| 185 | + # networked_player.gd |
| 186 | + extends KinematicBody2D |
| 187 | + class_name NetworkedPlayer |
| 188 | + |
| 189 | + export var speed = 3.0 |
| 190 | + export server_tick_interval = 100 |
| 191 | + |
| 192 | + var _last_server_state = {} |
| 193 | + var _input_buff = [] |
| 194 | + var _accum = 0.0 |
| 195 | + var seq = 0 |
| 196 | + |
| 197 | + func move(direction): |
| 198 | + if direction.length() == 0: |
| 199 | + return |
| 200 | + var cmd = { |
| 201 | + "d": direction, |
| 202 | + "seq": seq, |
| 203 | + } |
| 204 | + rpc_unreliable_id(NetworkedMultiplayerPeer.TARGET_PEER_SERVER, "_buffer_input", cmd) |
| 205 | + _buffer_input(cmd) |
| 206 | + move_and_slide(direction*speed) |
| 207 | + seq+=1 |
| 208 | + |
| 209 | + func _process(delta): |
| 210 | + # Remote client peers should lerp to to the last known server |
| 211 | + # state. |
| 212 | + if not multiplayer.is_network_server() and not is_network_master(): |
| 213 | + _interpolate(delta) |
| 214 | + |
| 215 | + func _physics_process(delta): |
| 216 | + # If this player instance is the server, then it |
| 217 | + # is the source of truth. It should process the |
| 218 | + # buffered input and replicate it back to the client. |
| 219 | + if multiplayer.is_network_server(): |
| 220 | + var curr = OS.get_system_time_msecs() |
| 221 | + if (curr - _accum) >= server_tick_interval: |
| 222 | + _process_server_input(delta) |
| 223 | + _accum = curr |
| 224 | + |
| 225 | + # If this is the entity who sent the input, then reconcile |
| 226 | + # with whatever the last known server state is. This might |
| 227 | + # contradict what was sent if the sent input was invalid or |
| 228 | + # deemed incorrect by the server. |
| 229 | + elif is_network_master(): |
| 230 | + _reconcile(delta) |
| 231 | + |
| 232 | + # Server reconciliation. Directly set the current position to that |
| 233 | + # of the last known server state, and then reapply all inputs since then. |
| 234 | + # This ensures client consistency with the server state. |
| 235 | + # @see https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html |
| 236 | + func _reconcile(delta): |
| 237 | + var last_state = _last_server_state |
| 238 | + if last_state: |
| 239 | + # set the current position to the last known server state |
| 240 | + position = last_state.p |
| 241 | + |
| 242 | + # reapply any input since the last known server state. |
| 243 | + var del = [] |
| 244 | + while not _input_buff.empty(): |
| 245 | + var cmd = _input_buff.pop_front() |
| 246 | + if cmd.seq > last_state.seq: |
| 247 | + move_and_slide(cmd.d * speed) |
| 248 | + |
| 249 | + # Entity interpolation. Lerp remote entities to last known server state. This ensures eventual |
| 250 | + # remote client consistency with the server state. |
| 251 | + # @see https://www.gabrielgambetta.com/entity-interpolation.html |
| 252 | + func _interpolate(delta) -> void: |
| 253 | + var last_state = _last_server_state |
| 254 | + if last_state: |
| 255 | + # You can adjust the lerp weight to fit your needs. |
| 256 | + position = lerp(position, last_state.p, 0.5) |
| 257 | + |
| 258 | + |
| 259 | + func _process_server_state(delta): |
| 260 | + if _input_buff.empty(): |
| 261 | + return |
| 262 | + var last_seq = 0 |
| 263 | + while not _input_buff.empty(): |
| 264 | + var cmd = _input_buff.pop_front() |
| 265 | + last_seq = cmd.seq |
| 266 | + move_and_slide(cmd.d * speed) |
| 267 | + rpc_unreliable("_append_server_state, {"p": position, "seq": last_seq}) |
| 268 | + |
| 269 | + remote func _append_server_state(state): |
| 270 | + if multiplayer.get_rpc_sender_id() == NetworkedMultiplayerPeer.TARGET_PEER_SERVER: |
| 271 | + _last_server_state = state |
| 272 | + |
| 273 | + # Keep a buffer of inputs. This is executed on the server and the client |
| 274 | + # that sent the inputs. The server will use this buffer for processing, |
| 275 | + # while the client will use it for server reconciliation. |
| 276 | + puppet func _buffer_input(input : Dictionary) -> void: |
| 277 | + _input_buff.append(input) |
| 278 | + |
| 279 | +.. note:: For more information on entity interpolation, check out `the article by Gabriel Gambetta <https://www.gabrielgambetta.com/entity-interpolation.html>`__. |
| 280 | + |
| 281 | +Conclusion |
| 282 | +---------- |
| 283 | + |
| 284 | +This tutorial preovided a brief explanation of server authoritative client-server games and explained the challenges that this model brings to player movement, as well |
| 285 | +as some of the techniques that can be used to solve these problems. These techniques are commonly used in many online multiplayer games, however depending on your game's |
| 286 | +design, other techniques may be required either in lieu of, or in addition to the ones described here. Some of these techniques include lag compensation, which is common |
| 287 | +in fast-paced first-person shooters and dead reckoning, prevailent in most online racing games. For more reading on these topics, I can recommend the fantastic book series |
| 288 | +by "No Bugs Hare" called *Development and Deployment of Multiplayer Online Games*. Vol I. is available `on Amazon <https://www.amazon.com/Development-Deployment-Multiplayer-Online-Games/dp/3903213055>`__ |
| 289 | +and the beta versions of the rest of the series is available on `No Bugs' website <http://ithare.com/contents-of-development-and-deployment-of-massively-multiplayer-games-from-social-games-to-mmofps-with-stock-exchanges-in-between/>`__ for free. |
0 commit comments