Fresh start: replace with naxIO/netfox-cs-sample foundation
Complete replacement of the tactical-shooter project with the netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built with Godot 4 and netfox. ## What's new - Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu - 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP - Bomb plant/defuse with 2 bombsites - Flashbang & smoke grenades - Proper netfox rollback netcode at 64 tick - Network popup UI for host/join - HUD, crosshair, round timer, scoreboard - All netfox singletons registered as autoloads (works in exported builds) ## Architecture - Listen-server (host from client, no dedicated server binary) - Multiplayer-fps game lives at examples/multiplayer-fps/ - Netfox addons registered as autoloads for exported build compat - Godot 4.7 with Forward+ renderer ## Removed - Old headless-server architecture (client_main, server_main, player.gd, etc.) - Custom netfox bootstrap with ENet fallback - Old ChaffGames FPS template (2,420 lines, 844 KB) - SimulationServer GDExtension stub - Godot-jolt physics (netfox sample uses default Godot physics) - Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc. - Server browser API Python venv (87 MB) - test_range map and modular assets ## Preserved - Git history - Server config at config/default_server_config.cfg - Windows export preset - Build directory (gitignored) Co-authored-by: naxIO <naxIO@users.noreply.github.com>
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,64 @@
|
||||
# Authoritative servers
|
||||
|
||||
The idea behind multiplayer servers is replicating state. As long each player
|
||||
sees approximately the same things happening on their screen, the illusion of a
|
||||
shared world works.
|
||||
|
||||
## Naive replication
|
||||
|
||||
To implement state replication, we could say that each player is responsible
|
||||
for their own state. Players see the effects of their input instantly, as they
|
||||
own their state and thus their avatar.
|
||||
|
||||
The issue is that clients can't be trusted. Your game client is distributed to
|
||||
players, who run it in various environments. These environments are out of the
|
||||
developer's control, and provide an attack surface for cheats.
|
||||
|
||||
For example, a modified game client might always report full HP no matter how
|
||||
many hits the player takes. If each player is responsible for their own state,
|
||||
the cheating player's full-HP state will be replicated to everyone else.
|
||||
|
||||
## Server as the source of truth
|
||||
|
||||
What can be controlled is the server, with dedicated hosting. Thus, the server
|
||||
can be the single source of truth - or in other words, authoritative. Clients
|
||||
send their inputs to the server, and the server responds with the updated game
|
||||
state.
|
||||
|
||||
This makes cheating difficult, as players have limited influence over the game
|
||||
world.
|
||||
|
||||
Game code can also be simplified - everything that affects the gameplay is run
|
||||
on the server, while other things such as visual effects are run on the
|
||||
clients.
|
||||
|
||||
The tradeoff is that it takes time for the updated game state to arrive from
|
||||
the server. This necessitates techniques that mask this delay, such as
|
||||
[Client-side prediction and Server reconciliation].
|
||||
|
||||
## Other approaches
|
||||
|
||||
Server-authoritative gameplay with CSP is not a silver bullet unfortunately,
|
||||
and different games may require different approaches to network state
|
||||
replication.
|
||||
|
||||
One good example is RTS games. These games can have 50+ or even hundreds of
|
||||
units navigating the map and interacting. Broadcasting all of their state to
|
||||
all of the players from the server may not always be feasible.
|
||||
|
||||
Instead, players broadcast their actions ( inputs ) to each other and update
|
||||
their game state in lockstep. While this approach can scale up to hundreds of
|
||||
units, it has other drawbacks. One of these is developing the game in such a
|
||||
way that the simulation is exactly the same across multiple CPU architectures
|
||||
down to each bit.
|
||||
|
||||
For more on this approach, see: [1500 Archers on a 28.8: Network Programming in
|
||||
Age of Empires and Beyond]
|
||||
|
||||
For more approaches, see: [Networking for Physics Programmers]
|
||||
|
||||
[1500 Archers on a 28.8: Network Programming in Age of Empires and Beyond]: https://www.gamedeveloper.com/programming/1500-archers-on-a-28-8-network-programming-in-age-of-empires-and-beyond
|
||||
|
||||
[Networking for Physics Programmers]: https://www.gdcvault.com/play/1022195/Physics-for-Game-Programmers-Networking
|
||||
|
||||
[Client-side prediction and Server reconciliation]: https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html
|
||||
@@ -0,0 +1,35 @@
|
||||
# Servers, clients, and ownership
|
||||
|
||||
Much of this documentation discusses things in context of servers and clients.
|
||||
This page is intended to clear up how this translates to Godot's concept of
|
||||
multiplayer ownership.
|
||||
|
||||
## Ownership in Godot
|
||||
|
||||
In Godot's multiplayer system, each node belongs to a multiplayer peer, i.e. a
|
||||
player. This can be set from scripts, and is not replicated. This means that
|
||||
the logic assigning ownership to nodes must produce the same result on every
|
||||
machine for things to work consistently.
|
||||
|
||||
## Ownership in netfox
|
||||
|
||||
To mesh better with Godot's existing conventions, *netfox* doesn't work in
|
||||
terms of server and client, but uses ownership instead.
|
||||
|
||||
Whenever *the server* is mentioned, it refers to a given node's owner.
|
||||
|
||||
In practice, this means that nodes representing game state are and should be
|
||||
owned by the server.
|
||||
|
||||
## Limitations
|
||||
|
||||
At the time of writing, ownership is hard-coded in some cases. One such case is
|
||||
*NetworkTime*, which is always owned by the host peer and always takes the host
|
||||
peer's time as reference.
|
||||
|
||||
This means that peer-to-peer games are not officially supported by *netfox*,
|
||||
but might be able to work with some workarounds. If feasible, you can build
|
||||
self-hosted games by including *netfox.noray*.
|
||||
|
||||
In theory, multiple players can own different parts of the game state, but
|
||||
*netfox* is not tested for such use cases.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Interpolators
|
||||
|
||||
Tracks interpolators for various data types. Provided as a static class.
|
||||
|
||||
To smooth out motion between network ticks, [TickInterpolator] interpolates
|
||||
nodes' state properties between the current and the previous tick. The type of
|
||||
data to be interpolated is not known in advance, and can be any built-in or
|
||||
even custom type configured by the developer.
|
||||
|
||||
*Interpolators* provides methods to register interpolators for any data type,
|
||||
and even provides some for built-in data types.
|
||||
|
||||
## Interpolating values
|
||||
|
||||
*Interpolators* can be used to interpolate between any two values, as long as
|
||||
they hold the same data type:
|
||||
|
||||
```gdscript
|
||||
extends Node3D
|
||||
|
||||
@export var target_node
|
||||
@export var approach_time = 0.5
|
||||
|
||||
func _process(delta):
|
||||
# Approach target node, if it exists
|
||||
if target_node:
|
||||
var from_xform = global_transform
|
||||
var to_xform = target_node.global_transform
|
||||
var factor = delta / approach_time
|
||||
|
||||
global_transform = Interpolators.interpolate(from_xform, to_xform, factor)
|
||||
```
|
||||
|
||||
Note that in this case, *Interpolators* will try to look up the appropriate
|
||||
interpolator based on the provided values. If no interpolator is found, a
|
||||
fallback is used, that simply returns the value closer to *factor* - i.e. the
|
||||
starting value if *factor* is less than 0.5 and the target value otherwise.
|
||||
|
||||
## Caching interpolators
|
||||
|
||||
To avoid having to look up the right interpolator every frame, you can cache
|
||||
it:
|
||||
|
||||
```gdscript
|
||||
extends Node3D
|
||||
|
||||
@export target_node
|
||||
@export approach_time = 0.5
|
||||
|
||||
var interpolator
|
||||
|
||||
func _ready():
|
||||
interpolator = Interpolators.find_for(global_transform)
|
||||
|
||||
func _process(delta):
|
||||
# Approach target node, if it exists
|
||||
if target_node:
|
||||
var from_xform = global_transform
|
||||
var to_xform = target_node.global_transform
|
||||
var factor = delta / approach_time
|
||||
|
||||
global_transform = interpolator.call(from_xform, to_xform, factor)
|
||||
```
|
||||
|
||||
## Custom interpolators
|
||||
|
||||
*Interpolators* supports interpolators for custom data types, and even
|
||||
overriding built-in interpolators. Both can be done by registering an
|
||||
interpolator:
|
||||
|
||||
```gdscript
|
||||
Interpolators.register(
|
||||
func(a): return a is float, # Condition
|
||||
func(a, b, f): return lerpf(a, b, f * f) # Interpolation
|
||||
)
|
||||
```
|
||||
|
||||
The above registers a custom interpolator by specifying a condition function
|
||||
and an interpolation function. Since it applies to an already supported type,
|
||||
it overrides the built-in interpolator.
|
||||
|
||||
During lookup, *Interpolators* calls the *condition* function of each
|
||||
interpolator and returns the one whose *condition* function returns true. If
|
||||
multiple interpolators are applicable, *Interpolators* returns the last
|
||||
registered one.
|
||||
|
||||
The *interpolation* function receives the starting value *a*, the target value
|
||||
*b* and the interpolation factor *f*.
|
||||
|
||||
## Built-in interpolators
|
||||
|
||||
The following types are supported by default:
|
||||
|
||||
* float
|
||||
* Vector2
|
||||
* Vector3
|
||||
* Transform2D
|
||||
* Transform3D
|
||||
|
||||
[TickInterpolator]: ../nodes/tick-interpolator.md
|
||||
@@ -0,0 +1,148 @@
|
||||
# Logging
|
||||
|
||||
During runtime, it can be useful to print some diagnostic info to the console -
|
||||
this is called logging. The netfox addons include a logging system to help with
|
||||
debugging. This is useful when running the game locally, but also helps if
|
||||
there's log files players can attach with their bug reports.
|
||||
|
||||
The system produces logs like this:
|
||||
|
||||
```
|
||||
[DBG][@0][#1][_][netfox::NetworkPerformance] Network performance enabled, registering performance monitors
|
||||
[DBG][@0][#1][_][netfox.extras::WindowTiler] Tiling with sid: f2682d1, uid: 17627261006193110
|
||||
[DBG][@0][#1][_][netfox.extras::NetworkSimulator] Feature disabled
|
||||
[DBG][@0][#1][_][netfox.extras::WindowTiler] Tiling as idx 0 / 1 - 17627261006193110 in ["17627261006193110"]
|
||||
[DBG][@22][#1][_][netfox.extras::NetworkWeapon] Calling after fire hook for Bomb Projectile 5sswh7lcsgbq
|
||||
[DBG][@27][#1][_][fb::Displacer] Created explosion at (2.027323, 1.500942, -14.99592)@26
|
||||
[DBG][@34][#1][_][netfox.extras::NetworkWeapon] Calling after fire hook for Bomb Projectile u4h8opz52lin
|
||||
[DBG][@46][#1][_][fb::Displacer] Created explosion at (4.892477, 1.500942, -14.83388)@45
|
||||
[DBG][@46][#1][_][netfox.extras::NetworkWeapon] Calling after fire hook for Bomb Projectile 2u1d9n456yl1
|
||||
[DBG][@57][#1][_][fb::Displacer] Created explosion at (4.814114, 1.500942, -14.57117)@56
|
||||
```
|
||||
|
||||
This page will elaborate on how to produce your own logs, and what each part
|
||||
means.
|
||||
|
||||
## Using the logger
|
||||
|
||||
The logging system can be accessed by creating an instance of `NetfoxLogger`.
|
||||
Every logger has a name, and belongs to a module. Both of these can be
|
||||
arbitrary strings, and are included in the logged messages.
|
||||
|
||||
Messages can be logged as different *logging levels*:
|
||||
|
||||
```gd
|
||||
var logger := NetfoxLogger.new("my-game", "Player")
|
||||
logger.trace("Detailed message")
|
||||
logger.debug("Something happened")
|
||||
logger.info("Hi!")
|
||||
logger.warning("Couldn't connect")
|
||||
logger.error("Game missing?")
|
||||
```
|
||||
|
||||
To use string interpolation, you can also pass the template string and values
|
||||
separately. This can be useful to avoid substituting the values in case the
|
||||
message never gets printed because of filtering:
|
||||
|
||||
```gd
|
||||
logger.trace("Adjusted clock by %.2fms, offset: %.2fms, new time: %.4fss", [nudge * 1000., offset * 1000., _clock.get_time()])
|
||||
```
|
||||
|
||||
In the above example, there's a lot of data to be included in the message.
|
||||
However, if trace logs are disabled, that data will never be substituted,
|
||||
saving some processing time.
|
||||
|
||||
!!!tip
|
||||
This same logging system is used by netfox itself.
|
||||
|
||||
## Log levels
|
||||
|
||||
Each log message can belong to one of the following categories:
|
||||
|
||||
Error
|
||||
: Something goes irrecoverably wrong, or something that should never happen
|
||||
just happened
|
||||
|
||||
Warning
|
||||
: Something goes wrong, but can be handled
|
||||
|
||||
Info
|
||||
: Useful information on expected behaviour
|
||||
|
||||
Debug
|
||||
: Verbose messages, to help debug general code flow
|
||||
|
||||
Trace
|
||||
: Extremely verbose messages, to help follow the code flow to the smallest
|
||||
detail
|
||||
|
||||
Depending on your game, different logs may be needed. To accommodate this,
|
||||
*netfox* can be configured in the [Project Settings](#settings) to omit certain
|
||||
log messages.
|
||||
|
||||
Filtering based on log levels can also be configured from code. To set the
|
||||
global log level, set `NetfoxLogger.log_level`. To configure the log level per
|
||||
module, use the `NetfoxLogger.module_log_level` dictionary.
|
||||
|
||||
## Tags
|
||||
|
||||
Tags can be attached to the logging system. They provide pieces of information
|
||||
that appear in each log message, for every logger.
|
||||
|
||||
By default, netfox provides a few tags, to help with debugging. These are, in
|
||||
order:
|
||||
|
||||
Current tick
|
||||
: The current tick, as per `NetworkTime`
|
||||
|
||||
Peer ID
|
||||
: The currently active multiplayer peer's ID
|
||||
|
||||
Rollback status
|
||||
: Contains the current rollback stage, simulated tick, and resimulated tick
|
||||
interval.
|
||||
|
||||
The stage can be `B` for before loop, `P` for prepare tick, `S` for
|
||||
simulate tick, `R` for record tick, and `A` for after loop.
|
||||
|
||||
The current tick is in the form of `X|A>B`, meaning we're currently
|
||||
simulating tick X, in a loop going from tick A to tick B.
|
||||
|
||||
Defaults to `_` if currently not in rollback.
|
||||
|
||||
!!!note
|
||||
These default tags are subject to change between releases.
|
||||
|
||||
Custom tags can be attached by calling `NetfoxLogger.register_tag()`. In this
|
||||
sense, tags are callbacks that must return a single string, containing the tag
|
||||
data to be logged.
|
||||
|
||||
This method takes a second, `priority` parameter. This priority is used to sort
|
||||
them for logging - tags are printed from lowest priority to highest.
|
||||
|
||||
!!!warning
|
||||
Make sure to free your custom tags using `NetfoxLogger.free_tag()`. Not
|
||||
doing so might cause crashes. See [#433] for details.
|
||||
|
||||
## Settings
|
||||
|
||||

|
||||
|
||||
These settings control the *minimum* log level - e.g. if the log level is set
|
||||
to *info*, only messages at or above the info level will be logged, namely
|
||||
info, warning and error. If the setting is set to *all*, all messages are
|
||||
logged.
|
||||
|
||||
Log levels can be controlled globally and per addon. A message will be logged
|
||||
if it passes *both* logging level checks.
|
||||
|
||||
For example, if the *Log Level* setting is at *Warning* and the *Netfox Log
|
||||
Level* is at *Info*, only warning and error messages are logged for netfox.
|
||||
This happens because the *Log Level* is more restrictive than the *Netfox Log
|
||||
Level* setting.
|
||||
|
||||
Note that you don't need to install all netfox addons for the logging settings
|
||||
to work. If an addon is not installed, its log level setting is simply ignored.
|
||||
|
||||
|
||||
[#433]: https://github.com/foxssake/netfox/issues/433
|
||||
@@ -0,0 +1,98 @@
|
||||
# Netfox Sharp
|
||||
|
||||
!!!warning
|
||||
**[Netfox Sharp] is currently an experimental build and not ready for
|
||||
production. During this time, breaking changes may be introduced at any
|
||||
time.**
|
||||
|
||||
The [Netfox Sharp] addon is designed to bridge the gap between GDScript and C#
|
||||
by allowing core netfox features to be accessed in C# without having to worry
|
||||
about [Cross-Language Scripting] with GDScript directly.
|
||||
|
||||
### What Netfox Sharp Is
|
||||
|
||||
- A wrapper for netfox that uses the existing netfox codebase for its logic.
|
||||
- A way to more conveniently call netfox logic in a C# environment.
|
||||
- Partially compatible with existing codebases that use GDScript.
|
||||
|
||||
### What Netfox Sharp Isn't
|
||||
|
||||
- A standalone addon written entirely in C#.
|
||||
- A perfect 1:1 translation. Due to quirks of netfox, some code will differ,
|
||||
detailed below.
|
||||
- A wrapper for netfox.noray or netfox.extras. Support for either of those
|
||||
currently isn't planned, but may be considered based on interest.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- Download the [Netfox Sharp] repo, and move the `netfox_sharp` and
|
||||
`netfox_sharp_internals` folders into the addons of a C#-enabled Godot
|
||||
project using the .NET version of Godot 4.x.
|
||||
- Install the netfox addon. See the Netfox Sharp repo for details on which
|
||||
version of netfox you need.
|
||||
- Build your project, then enable netfox and Netfox Sharp in your project
|
||||
settings.
|
||||
- Restart Godot, and you've successfully set up Netfox Sharp!
|
||||
|
||||
## Differences Between Netfox And Netfox Sharp
|
||||
|
||||
Existing documentation for netfox should be easily translatable to Netfox Sharp
|
||||
by following the below differences.
|
||||
|
||||
- Most changes follow Godot's rules for [Cross-Language Scripting], taking
|
||||
netfox as the base. In netfox, consider the following:
|
||||
|
||||
```gdscript
|
||||
# The following example is a snippet of netfox code
|
||||
func _ready():
|
||||
NetworkTime.before_tick_loop.connect(_gather)
|
||||
|
||||
func _gather():
|
||||
# Input gathering here
|
||||
pass
|
||||
|
||||
func _rollback_tick(delta, tick, is_fresh):
|
||||
# Rollback logic here
|
||||
pass
|
||||
```
|
||||
|
||||
Whereas in Netfox Sharp:
|
||||
|
||||
```cs
|
||||
// This is functionally identical Netfox Sharp code
|
||||
public override void _Ready()
|
||||
{
|
||||
// All netfox autoloads like NetworkTime are accessed through static members
|
||||
// in NetfoxSharp, to save on GetNode() calls and reduce clutter in the
|
||||
// project settings.
|
||||
|
||||
// All members like BeforeTickLoop are in PascalCase, similar to Godot's C#
|
||||
NetfoxSharp.NetworkTime.BeforeTickLoop += Gather;
|
||||
}
|
||||
|
||||
// As Gather is linked to a signal, it can be any naming convention.
|
||||
private void Gather()
|
||||
{
|
||||
// Input gathering here
|
||||
}
|
||||
|
||||
// Since _rollback_tick isn't connected to a signal and is instead handled by
|
||||
// netfox internally, netfox's naming convention must be followed.
|
||||
public void _rollback_tick(double delta, long tick, bool isFresh)
|
||||
{
|
||||
// Rollback logic here
|
||||
}
|
||||
```
|
||||
|
||||
- Nodes in the add mode menu have similar names to the GDScript version, but
|
||||
with 'Sharp' affixed, IE `RollbackSynchronizerSharp`. The GDScript versions
|
||||
of the nodes are also present in the add node menu. This is a limitation of
|
||||
how netfox interacts with Godot and cannot be removed.
|
||||
|
||||
# Other Notes
|
||||
- `RollbackSynchronizerSharp`, `StateSynchronizerSharp`, and
|
||||
`TickInterpolatorSharp` create their own respective GDScript nodes, which are
|
||||
instanced as internal children nodes and should not be accessed.
|
||||
|
||||
[Cross-Language Scripting]: https://docs.godotengine.org/en/stable/tutorials/scripting/cross_language_scripting.html
|
||||
[Netfox Sharp]: https://github.com/CyFurStudios/NetfoxSharp/
|
||||
@@ -0,0 +1,94 @@
|
||||
# NetworkCommandServer
|
||||
|
||||
Implements a simpler, lightweight alternative to RPCs. Provided as an autoload.
|
||||
|
||||
Commands consist of a single byte for ID, and the raw binary data. The ID lets
|
||||
the receiving peer decide what to execute, with the binary data serving as the
|
||||
input.
|
||||
|
||||
Being a simpler construct makes commands a good fit for regular, fundamental
|
||||
operations. For example, commands internally are used for time synchronization,
|
||||
or synchronizing state and input between peers.
|
||||
|
||||
Commands are, by default, transmitted over regular RPCs. To use less data,
|
||||
commands can also be transmitted as raw packets, using
|
||||
[SceneMultiplayer.send_bytes()]. This is an opt-in feature - if the game is
|
||||
already using [SceneMultiplayer.send_bytes()], it needs to be aware of
|
||||
commands, and must check each packet whether it's a command or one of its own
|
||||
packets. To check if a packet is a command, use `is_command_packet()`.
|
||||
|
||||
## Implementing custom commands
|
||||
|
||||
Custom commands can be registered with the *NetworkCommandServer*, using
|
||||
`register_command()`. This returns a *Command* object that provides a
|
||||
convenient interface.
|
||||
|
||||
During registration, a callback must be provided, that will be ran when the
|
||||
command is received.
|
||||
|
||||
Commands can be sent using its `send()` method.
|
||||
|
||||
```gdscript
|
||||
@onready var cmd_message := NetworkCommandServer.register_command(handle_message, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
|
||||
|
||||
func handle_message(sender: int, data: PackedByteArray) -> void:
|
||||
var message := data.get_string_from_utf8()
|
||||
print("#%d: %s" % [sender, message])
|
||||
|
||||
func _ready() -> void:
|
||||
cmd_message.send("Hello, world!".to_utf8_buffer())
|
||||
```
|
||||
|
||||
!!!tip
|
||||
It is recommended to setup commands once, at game start. When registering
|
||||
commands from autoloads, make sure they run *after* netfox's autoloads.
|
||||
|
||||
## Differences compared to RPCs
|
||||
|
||||
Commands are a fundamentally simpler constructs compared to RPCs.
|
||||
|
||||
### Maximum 256 commands
|
||||
|
||||
Commands are limited to 256 indices - make sure to not register more than that.
|
||||
Some commands are registered by netfox on startup as well.
|
||||
|
||||
This limitation also makes commands a poor fit for registering dynamically.
|
||||
Dynamic registrations often mean registering commands as certain nodes or
|
||||
objects are created. This, in turn, makes it difficult to place an upper bound
|
||||
on the number of commands needed, which can conflict with this limitation.
|
||||
|
||||
### Commands are not tied to any node
|
||||
|
||||
Commands do not refer to any specific node or object in their content. They
|
||||
only contain a command index. Even though the API encapsulates this into
|
||||
*Command* objects, it is completely feasible to have different nodes handle the
|
||||
same command on different peers ( if the game is built as different Godot
|
||||
projects ).
|
||||
|
||||
### Commands do not track authority
|
||||
|
||||
Any peer can send any command to any other peer. It is the receiving peer's
|
||||
responsibility to check whether the sender is allowed to send such a command or
|
||||
not.
|
||||
|
||||
### Commands do not have arguments
|
||||
|
||||
To stay lightweight and to give maximum control, commands contain raw bytes
|
||||
only, no arguments.
|
||||
|
||||
In general, this can be worked around by wrapping the arguments in an array and
|
||||
converting it using [var_to_bytes()] and [bytes_to_var()].
|
||||
|
||||
However, for cases where bandwidth matters, this allows users to encode data in
|
||||
a way that fits best.
|
||||
|
||||
## Settings
|
||||
|
||||
netfox ▸ General ▸ Use Raw Commands
|
||||
|
||||
: When enabled, netfox will transmit commands as raw packets, instead of RPCs.
|
||||
|
||||
|
||||
[SceneMultiplayer.send_bytes()]: https://docs.godotengine.org/en/stable/classes/class_scenemultiplayer.html#class-scenemultiplayer-method-send-bytes
|
||||
[var_to_bytes()]: https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#class-globalscope-method-var-to-bytes
|
||||
[bytes_to_var()]: https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#class-globalscope-method-bytes-to-var
|
||||
@@ -0,0 +1,28 @@
|
||||
# NetworkEvents
|
||||
|
||||
Provides convenience signals for multiplayer games. Included as an autoload.
|
||||
|
||||
Keeps track of the root *multiplayer* instance and fires signals when it
|
||||
changes. Using *NetworkEvents*' signals are safe even when the *multiplayer*
|
||||
instance changes, as the signals are updated upon instance change.
|
||||
|
||||
Provides missing signals for server start and server stop events.
|
||||
|
||||
## NetworkTime
|
||||
|
||||
When enabled, *NetworkEvents* will start [NetworkTime] when it detects that a
|
||||
server or a client is started. It will stop [NetworkTime], when it detects that
|
||||
the currently running server or client is stopped.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are found in the Project Settings, under Netfox > Events:
|
||||
|
||||

|
||||
|
||||
*Enabled* toggles network events. When disabled, *NetworkEvents* will not emit
|
||||
any events or track the multiplayer instance. This may slightly improve
|
||||
performance, as it completely stops Godot from processing the *NetworkEvents*
|
||||
node.
|
||||
|
||||
[NetworkTime]: ./network-time.md
|
||||
@@ -0,0 +1,69 @@
|
||||
# NetworkPerformance
|
||||
|
||||
Provides [custom monitors] for measuring networking performance. Included as an
|
||||
autoload.
|
||||
|
||||
## Enabling monitoring
|
||||
|
||||
By default, network performance monitoring is only enabled in debug builds and
|
||||
when running from the editor.
|
||||
|
||||
Use the `netfox_noperf` feature tag to force disable network performance
|
||||
monitors.
|
||||
|
||||
Use the `netfox_perf` feature tag to force enable network performance monitors.
|
||||
|
||||
These feature tags enable customization for each export preset.
|
||||
|
||||
## Performance monitors
|
||||
|
||||
### Network loop duration
|
||||
|
||||
*Network loop duration* measures the time spent in the [network tick loop].
|
||||
Note that this includes time spent on the [rollback loop] as well.
|
||||
|
||||
This value is updated once for every tick loop, it is not reset to zero after
|
||||
the loop has run. This means that you may get a non-zero reading, even if the
|
||||
tick loop is currently not running.
|
||||
|
||||
### Rollback loop duration
|
||||
|
||||
*Rollback loop duration* measures the time spent in the last [rollback loop].
|
||||
This includes all of its steps.
|
||||
|
||||
The value of this monitor may be zero, if no players have joined, no nodes use
|
||||
rollback, or rollback is disabled.
|
||||
|
||||
### Network ticks simulated
|
||||
|
||||
*Network ticks simulated* measures the number of ticks run in the last [network
|
||||
tick loop]. If the game runs at a higher FPS than the network tickrate, this
|
||||
value should be consistently one.
|
||||
|
||||
Higher, stable values mean that the game itself runs slower than the network
|
||||
tickrate, and needs to catch up by running multiple ticks on each frame.
|
||||
|
||||
### Rollback ticks simulated
|
||||
|
||||
*Rollback ticks simulated* measures the number of rollback ticks run in the
|
||||
last [rollback loop]. Generally, this denotes the age of the oldest input *or*
|
||||
state received, depending on whether the game is running as a server or client.
|
||||
|
||||
The measurement is strongly correlated to network latency - the higher the
|
||||
latency, the older the state and input packets will be upon arrival.
|
||||
|
||||
The more rollback ticks need to be simulated, the more work the rollback tick
|
||||
has to do, which can negatively affect performance.
|
||||
|
||||
### Rollback tick duration
|
||||
|
||||
*Rollback tick duration* provides the average time spent simulating a single
|
||||
tick in the last [rollback loop].
|
||||
|
||||
This can be useful to determine if the rollback tick duration comes from too
|
||||
many ticks being simulated, or the individual ticks being expensive to
|
||||
simulate ( or both ).
|
||||
|
||||
[custom monitors]: https://docs.godotengine.org/en/latest/classes/class_performance.html#class-performance-method-add-custom-monitor
|
||||
[network tick loop]: ./network-time.md
|
||||
[rollback loop]: ./network-rollback.md
|
||||
@@ -0,0 +1,191 @@
|
||||
# NetworkRollback
|
||||
|
||||
Orchestrates the network rollback loop. Provided as an autoload.
|
||||
|
||||
Due to latency, the server may receive inputs from clients from multiple ticks
|
||||
ago. Whenever this happens, the server rewinds its time and resimulates the
|
||||
whole game from the time of the new input. The resimulated ticks are then sent
|
||||
to clients to update their state.
|
||||
|
||||
Also due to latency, clients may receive a state from the server that is
|
||||
several ticks old. Clients rewind their simulation to the time of the latest
|
||||
received state and resimulate from there.
|
||||
|
||||
On both clients and servers, simulated states are recorded for reuse later.
|
||||
|
||||
Further reading: [Client-Side Prediction and Server Reconciliation]
|
||||
|
||||
Note that most of the time you do not need to use this class - the
|
||||
[RollbackSynchronizer] node helps with writing rollback-aware behaviour.
|
||||
|
||||
## Network rollback loop
|
||||
|
||||
*NetworkRollback* runs the *network rollback loop* after every network tick,
|
||||
but before the *after tick* signal is fired.
|
||||
|
||||
The following is the network rollback loop in isolation:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
start
|
||||
|
||||
:before_loop;
|
||||
while(Rollback)
|
||||
:on_prepare_tick;
|
||||
:after_prepare_tick;
|
||||
:on_process_tick;
|
||||
:on_record_tick;
|
||||
endwhile
|
||||
:after_loop;
|
||||
|
||||
stop
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
Signal handlers must implement the right steps for rollback to work.
|
||||
|
||||
During *before_loop*, all rollback-aware nodes must submit where to start the
|
||||
resimulation, by calling `NetworkRollback.notify_resimulation_start`.
|
||||
Resimulation will begin from the earliest tick submitted.
|
||||
|
||||
In each *on_prepare_tick(tick)* handler, nodes must rewind their state to the
|
||||
specified tick. If a state is not available for the given tick, use the latest
|
||||
tick that is earlier than the given tick. Nodes may also register themselves as
|
||||
being simulated by calling `NetworkRollback.notify_simulated`. This is not used
|
||||
by *NetworkRollback* itself, but can be used by other nodes to check which
|
||||
nodes are simulated in the current rollback tick.
|
||||
|
||||
Before processing, *after_prepare_tick(tick)* is emitted. This is where any
|
||||
additional state- or input preparation may happen, such as [input prediction].
|
||||
|
||||
For the *on_process_tick(tick)* signal, nodes must advance their simulation by
|
||||
a single tick.
|
||||
|
||||
In *on_record_tick(tick)*, nodes must record their state for the given tick.
|
||||
Note that since the simulation was advanced by one tick in the previous signal,
|
||||
the *tick* parameter is incremented here.
|
||||
|
||||
The *after_loop* signal notifies its subscribers that the resimulation is done.
|
||||
This can be used to change to the state that is appropriate for display.
|
||||
|
||||
The network rollback loop is part of the network tick loop as follows:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
start
|
||||
|
||||
:NetworkTime.before_tick_loop;
|
||||
|
||||
while (Ticks to simulate) is (>0)
|
||||
:NetworkTime.before_tick;
|
||||
:NetworkTime.on_tick;
|
||||
:NetworkTime.after_tick;
|
||||
endwhile
|
||||
|
||||
:NetworkRollback.before_loop;
|
||||
while(Rollback)
|
||||
:NetworkRollback.on_prepare_tick;
|
||||
:NetworkRollback.after_prepare_tick;
|
||||
:NetworkRollback.on_process_tick;
|
||||
:NetworkRollback.on_record_tick;
|
||||
endwhile
|
||||
:NetworkRollback.after_loop;
|
||||
|
||||
:NetworkTime.after_tick_loop;
|
||||
|
||||
stop
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
The rollback tick loop is triggered in the `NetworkTime.after_tick_loop`
|
||||
signal. Since the rollback tick loop is the first thing connected to it, in
|
||||
practice the rollback will run *before* any user code connected to the
|
||||
`after_tick_loop` signal.
|
||||
|
||||
## Conditional simulation
|
||||
|
||||
During rollback, *NetworkRollback* loops over the full range of ticks to
|
||||
resimulate. Some nodes may not need to be resimulated for the current tick,
|
||||
e.g. because they don't have input for the current tick.
|
||||
|
||||
*NetworkRollback* can be used to track nodes that will be simulated in the
|
||||
current rollback tick. Register nodes that will be simulated by calling
|
||||
`NetworkRollback.notify_simulated`. To check if a node has been registered,
|
||||
call `NetworkRollback.is_simulated`.
|
||||
|
||||
## Rollback-awareness
|
||||
|
||||
[RollbackSynchronizer] considers nodes rollback-aware that implement the
|
||||
`_rollback_tick` method. Rollback-aware nodes are nodes that can participate in
|
||||
the rollback process, i.e. they can resimulate earlier ticks.
|
||||
|
||||
To check if a node is rollback-aware, call `NetworkRollback.is_rollback_aware`.
|
||||
To actually run a rollback tick on them, call
|
||||
`NetworkRollback.process_rollback`.
|
||||
|
||||
These methods are called by [RollbackSynchronizer] under the hood.
|
||||
|
||||
## Input Submission Status
|
||||
|
||||
In certain scenarios you may wish to delay committing to something hard to
|
||||
reverse like death, VFX or audio until its known for sure the outcome won't
|
||||
change. One way of doing this is to check which nodes have submitted input and
|
||||
are past a point of rollback.
|
||||
|
||||
You can query the status of Nodes with
|
||||
`NetworkRollback.get_latest_input_tick(root_node)` or
|
||||
`NetworkRollback.has_input_for_tick(root_node, tick)`. `root_node` being what
|
||||
the relevant [RollbackSynchronizer] has configured.
|
||||
|
||||
All tracked nodes can be retrieved from
|
||||
`NetworkRollback.get_input_submissions()` which will return the entire
|
||||
`<root_node, latest_tick>` dictionary.
|
||||
|
||||
## Settings
|
||||
|
||||

|
||||
|
||||
*Enabled* toggles network rollback. No signals are fired when disabled.
|
||||
|
||||
*History limit* is the maximum number of recorded ticks to keep. Larger values
|
||||
enable further rewinds and thus larger latencies, but consume more memory for
|
||||
each node that is recorded.
|
||||
|
||||
*Input redundancy* This is the number of previous input ticks to send along with
|
||||
the current tick. We send data unreliably over UDP for speed. In the event a packet is
|
||||
lost or arrives out of order we add some redundancy. You can calculate your target
|
||||
reliability % packet success chance by using the formula
|
||||
`1 - (1 - packet_success_rate) ^ input_redundancy`.
|
||||
|
||||
*Display offset* specifies the age of the tick to display. By displaying an
|
||||
older state instead of the latest one, games can mask adjustments if a state
|
||||
update is received from the server. The drawback is that the game will have
|
||||
some latency built-in, as it reacts to player inputs with some delay. Setting
|
||||
to zero will always display the latest game state.
|
||||
|
||||
*Input delay* specifies the delay applied to player input, in ticks. This
|
||||
results in player inputs shifted into the future, e.g. if the player starts
|
||||
moving left on tick 37, it will be sent to the server as tick 39. This way,
|
||||
even if the input takes time to arrive, it will still be up to date, as long as
|
||||
the network latency is smaller than the input latency.
|
||||
|
||||
!!!warning
|
||||
[RollbackSynchronizer]'s `is_fresh` parameter may not work as expected with
|
||||
input delay. This happens because clients already receive data for the
|
||||
current tick, which means that the tick doesn't need to be resimulated, and
|
||||
as a result, no `_rollback_tick` callbacks are ran with `is_fresh` set to
|
||||
true.
|
||||
|
||||
This happens when network latency is smaller than the input delay.
|
||||
|
||||
*Enable diff states* toggles diff states. By sending only state properties that
|
||||
have changed, netfox can reduce the bandwidth needed to synchronize the game
|
||||
between peers. See [RollbackSynchronizer] on how this is done and configured.
|
||||
|
||||
[Client-Side Prediction and Server Reconciliation]: https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html
|
||||
[input prediction]: ../tutorials/predicting-input.md
|
||||
[RollbackSynchronizer]: ../nodes/rollback-synchronizer.md
|
||||
@@ -0,0 +1,169 @@
|
||||
# Network Schemas
|
||||
|
||||
By default, *netfox* uses Godot's [Binary serialization API] to serialize data
|
||||
before transmitting it over the network. This is designed to work under various
|
||||
circumstances, with various data types, without knowing anything about them in
|
||||
advance.
|
||||
|
||||
However, during development, developers often have knowledge about the
|
||||
individual properties, such as their type and possible range of values. In
|
||||
addition, some values may be less important as others, and thus can accept some
|
||||
loss of precision.
|
||||
|
||||
Schemas enable developers to specify how each property should be serialized,
|
||||
allowing them to use this knowledge to reduce packet sizes, and thus bandwidth
|
||||
usage.
|
||||
|
||||
## Lossless vs. lossy
|
||||
|
||||
Most serializers are either lossless or lossy. This section gives a short
|
||||
theoretical introduction on what each means and when are they useful.
|
||||
|
||||
### Lossless compression
|
||||
|
||||
When the same amount of information can be represented with less data ( bytes
|
||||
), it is *lossless compression*.
|
||||
|
||||
For example, to represent a 2D normal vector, we do not need to serialize both
|
||||
of its component ( x, y ). Since we know the vector's length to be 1 by
|
||||
definition, we can store the vector's angle compared to predetermined reference
|
||||
vector. From that, we can completely reconstruct the original vector on
|
||||
deserialization.
|
||||
|
||||
Another example is when the range of values the vector can take on is much
|
||||
smaller than its underlying datatype supports. For example, an inventory where
|
||||
items can't stack beyond 99. Instead of defaulting to a 64 bit integer, it is
|
||||
sufficient to serialize this data as a 8 bit integer. That is 1/8th of the
|
||||
original data, while still perfectly representing the range of values needed.
|
||||
|
||||
Lossless compression is an excellent tool, since the same information is kept,
|
||||
but with less data usage. Unfortunately, lossless compression is not feasible
|
||||
for every property.
|
||||
|
||||
### Lossy compression
|
||||
|
||||
If some information is lost when using less data ( bytes ) to represent a
|
||||
value, it is *lossy compression*. This can be useful in cases where the benefit
|
||||
of reduced packet size outweighs the drawbacks of lost information.
|
||||
|
||||
For example, movement vectors for NPCs may be serialized as half precision
|
||||
floats, instead of the default single precision. Since players don't directly
|
||||
control NPC's, they won't notice any difference between their original input
|
||||
and what was serialized.
|
||||
|
||||
While lossy compression can be a useful tool, it is important to judge whether
|
||||
the loss of information or precision does not detract too much from the game
|
||||
experience.
|
||||
|
||||
## Registering a schema
|
||||
|
||||
Both [RollbackSynchronizer] and [StateSynchronizer] expose a `set_schema()`
|
||||
method, that can be used to register the schema used for transmitting
|
||||
properties over the network. This method takes a dictionary, with the keys
|
||||
being property path strings, and the values being serializers:
|
||||
|
||||
```gdscript
|
||||
rollback_synchronizer.set_schema({
|
||||
":transform": NetworkSchemas.transform3f32(),
|
||||
":velocity": NetworkSchemas.vec3f32(),
|
||||
":speed": NetworkSchemas.float32(),
|
||||
":mass": NetworkSchemas.float32(),
|
||||
|
||||
"Input:movement": NetworkSchemas.vec3f32(),
|
||||
"Input:aim": NetworkSchemas.vec3f32()
|
||||
})
|
||||
```
|
||||
|
||||
## Built-in serializers
|
||||
|
||||
`NetworkSchemas` provides many built-in serializers in the form of static
|
||||
methods. Each supported type has multiple serializers for different sizes.
|
||||
|
||||
While many serializers are usable as-is, there are some generic ones that take
|
||||
other serializers as arguments. For example, `vec3t()` serializes a Vector3,
|
||||
and using the serializer passed to it to save each component of the vector.
|
||||
This way, `vec3t(float16())` will save 3 half-precision floats, ending up with
|
||||
6 bytes of data, while `vec3t(float32())` will save 3 single-precision floats,
|
||||
ending up with 12 bytes.
|
||||
|
||||
!!!note
|
||||
Many built-in serializers use half-precision floats. These are only
|
||||
supported in Godot 4.4 and up. Earlier versions fall back to
|
||||
single-precision floats.
|
||||
|
||||
For example, `float16()` may fall back to `float32()`, `vec2f16()` to
|
||||
`vec2f32()`, etc.
|
||||
|
||||
### Algebraic types
|
||||
|
||||
| Type | Methods | Size |
|
||||
|-----------------------|---------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| Booleans | `bool8()` | 1 byte |
|
||||
| Signed integers | `int8()`, `int16()`, `int32()`, `int64()` | 1, 2, 4, or 8 bytes |
|
||||
| Unsigned integers | `uint8()`, `uint16()`, `uint32()`, `uint64()` | 1, 2, 4, or 8 bytes |
|
||||
| Floats | `float16()`, `float32()`, `float64()` | 2, 4, or 8 bytes |
|
||||
| Vector2 | `vec2f16()`, `vec2f32()`, `vec2f64()` | 4, 8, or 16 bytes |
|
||||
| Vector3 | `vec3f16()`, `vec3f32()`, `vec3f64()` | 6, 8, or 24 bytes |
|
||||
| Vector4 | `vec4f16()`, `vec4f32()`, `vec4f64()` | 8, 16, or 32 bytes |
|
||||
| Quaternion | `quatf16()`, `quatf32()`, `quatf64()` | 8, 16, or 32 bytes |
|
||||
| Transform2D | `transform2f16()`, `transform2f32()`, `transform2f64()` | 12, 24, or 48 bytes |
|
||||
| Transform3D | `transform3f16()`, `transform3f32()`, `transform3f64()` | 24, 48, or 96 bytes |
|
||||
|
||||
### Compressed types
|
||||
|
||||
| Type | Methods | Size |
|
||||
|-----------------------|---------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| Numbers in `[0, 1]` | `ufrac8()`, `ufrac16()`, `ufrac32()` | 1, 2, or 4 bytes |
|
||||
| Numbers in `[-1, +1]` | `sfrac8()`, `sfrac16()`, `sfrac32()` | 1, 2, or 4 bytes |
|
||||
| Degrees | `degrees8()`, `degrees16()`, `degrees32()` | 1, 2, or 4 bytes |
|
||||
| Radians | `radians8()`, `radians16()`, `radians32()` | 1, 2, or 4 bytes |
|
||||
| Normalized 2D vectors | `normal2f16()`, `normal2f32()`, `normal2f64()` | 2, 4, or 8 bytes |
|
||||
| Normalized 3D vectors | `normal3f16()`, `normal3f32()`, `normal3f64()` | 4, 8, or 16 bytes |
|
||||
|
||||
### Generic types
|
||||
|
||||
| Type | Methods | Size |
|
||||
|-----------------------|---------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| Vector2 | `vec2t()` | `2 * sizeof(component)` |
|
||||
| Vector3 | `vec3t()` | `3 * sizeof(component)` |
|
||||
| Vector4 | `vec4t()` | `4 * sizeof(component)` |
|
||||
| Quaternion | `quatt()` | `4 * sizeof(component)` |
|
||||
| Transform2D | `transform2t()` | `6 * sizeof(component)` |
|
||||
| Transform3D | `transform3t()` | `12 * sizeof(component)` |
|
||||
| Normalized Vector2 | `normal2t()` | `sizeof(component)` |
|
||||
| Normalized Vector3 | `normal3t()` | `2 * sizeof(component)` |
|
||||
|
||||
### Collections and others
|
||||
|
||||
| Type | Methods | Size |
|
||||
|-----------------------|---------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| Arrays | `array_of()` | `sizeof(size) + array.size() * sizeof(item)` |
|
||||
| Dictionaries | `dictionary()` | `sizeof(size) + dictionary.size() * (sizeof(key) + sizeof(value))` |
|
||||
| Strings | `string()` | Size in UTF-8 + null-terminator at the end |
|
||||
| Variant | `variant()` | Same as [var_to_bytes()] |
|
||||
|
||||
## Implementing a custom serializer
|
||||
|
||||
Custom serializers are also supported. To implement one, extend the
|
||||
`NetworkSchemaSerializer` class, and implement the `encode()` and `decode()`
|
||||
methods.
|
||||
|
||||
For example, consider a `Node` serializer that encodes the node's path:
|
||||
|
||||
```gdscript
|
||||
--8<-- "examples/snippets/network-schemas/example-node-serializer.gd"
|
||||
```
|
||||
|
||||
This custom serializer can now be used in schemas:
|
||||
|
||||
```gdscript
|
||||
rollback_synchronizer.set_schema({
|
||||
"Input:target": ExampleNodeSerializer.new()
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
[Binary serialization API]: https://docs.godotengine.org/en/stable/tutorials/io/binary_serialization_api.html
|
||||
[RollbackSynchronizer]: ../nodes/rollback-synchronizer.md
|
||||
[StateSynchronizer]: ../nodes/state-synchronizer.md
|
||||
[var_to_bytes()]: https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#class-globalscope-method-var-to-bytes
|
||||
@@ -0,0 +1,91 @@
|
||||
# NetworkTimeSynchronizer
|
||||
|
||||
Synchronizes time to the host remote. Provided as an autoload.
|
||||
|
||||
Synchronization is done by continuously pinging the host remote, and using
|
||||
these samples to figure out clock difference and network latency. These are
|
||||
then used to gradually adjust the local clock to keep in sync.
|
||||
|
||||
## The three clocks
|
||||
|
||||
The process distinguishes three different clock concepts:
|
||||
|
||||
The *Remote clock* is the clock being synchronized to, running on the host peer.
|
||||
|
||||
The *Reference clock* is a local clock, running on the client, that is getting
|
||||
adjusted to match the Remote clock as closely as possible. This clock is
|
||||
unsuitable to use for gameplay, as it being regularly adjusted can lead to
|
||||
glitchy movement.
|
||||
|
||||
The *Simulation clock* is also a local clock, and is being synchronized to the
|
||||
Reference clock. The Simulation clock is guaranteed to only move forwards in
|
||||
time. It drives the [Network tick loop].
|
||||
|
||||
Most of the time you shouldn't need to interface with this class directly,
|
||||
instead you can use [NetworkTime].
|
||||
|
||||
## Synchronizing the Reference clock
|
||||
|
||||
Synchronization is done by regularly taking samples of the remote clock, and
|
||||
deriving roundtrip time and clock offset from each sample. These samples are
|
||||
then combined into a single set of stats - offset, roundtrip time and jitter.
|
||||
|
||||
*Offset* is the difference to the remote clock. Positive values mean the remote
|
||||
clock is ahead of the reference clock. Negative values mean that the remote
|
||||
clock is behind the reference clock. May also be called theta.
|
||||
|
||||
*Roundtrip time* is the time it takes for data to travel to the remote and then
|
||||
back over the network. Smaller roundtrip times usually mean faster network
|
||||
connections. May also be called delay or delta.
|
||||
|
||||
*Jitter* is the amount of variation in measured roundtrip times. The less
|
||||
jitter, the more stable the network connection usually.
|
||||
|
||||
These stats are then used to get a good estimate of the current time on the
|
||||
remote clock. The remote clock estimate is then used to slowly adjust ( nudge )
|
||||
the reference clock towards the remote clock's value.
|
||||
|
||||
This is done iteratively, to avoid large jumps in time, and to - when possible
|
||||
- only go forward in time, not backwards.
|
||||
|
||||
When the offset gets too significant, it means that the clocks are excessively
|
||||
out of sync. In these cases, a panic occurs and the reference clock is reset.
|
||||
|
||||
This process is inspired by the [NTPv4] RFC.
|
||||
|
||||
## Synchronizing the Simulation clock
|
||||
|
||||
While the Reference clock is in sync with the Remote clock, its time is not
|
||||
linear - it is not guaranteed to advance monotonously, and technically it's
|
||||
also possible for it to move backwards. This would lead to uneven tick loops (
|
||||
e.g. sometimes 3 ticks in a single loop, sometimes 1, sometimes 5), and by
|
||||
extension, uneven and jerky movement.
|
||||
|
||||
To counteract the above, the Simulation clock is introduced. It is synced to
|
||||
the Reference clock, but instead of adjusting it by adding small offsets to it,
|
||||
it is *stretched*.
|
||||
|
||||
Whenever the Simulation clock is ahead of the Reference clock, the it will
|
||||
slightly slow down, to allow the Reference clock to catch up. When the
|
||||
Reference clock is ahead of the Simulation clock, it will run slightly faster
|
||||
to catch up with the Reference clock.
|
||||
|
||||
These stretches are subtle enough to not disturb gameplay, but effective enough
|
||||
to keep the two clocks in sync.
|
||||
|
||||
The Simulation clock is handled by [NetworkTime].
|
||||
|
||||
## Characteristics
|
||||
|
||||
The above process works well regardless of latency - very similar results can
|
||||
be achieved with 50ms latency as with 250ms.
|
||||
|
||||
Synchronization is more sensitive to jitter. Less stable network connections
|
||||
produce more varied latencies, which makes it difficult to distinguish clock
|
||||
offsets from latency variations. This in turn leads to the estimated clock
|
||||
offset changing more often, which results in more clock adjustments.
|
||||
|
||||
[Network tick loop]: ./network-time.md#network-tick-loop
|
||||
[NetworkTime]: ./network-time.md
|
||||
[NTPv4]: https://datatracker.ietf.org/doc/html/rfc5905
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# NetworkTime
|
||||
|
||||
Tracks shared network time between players, and provides an event loop for
|
||||
synchronized game updates. Provided as an autoload.
|
||||
|
||||
A separate timer is provided for network ticks, making the network game update
|
||||
rate independent from rendering or physics frames.
|
||||
|
||||
## Network tick loop
|
||||
|
||||
*NetworkTime* provides its own independent event loop by exposing signals. This
|
||||
makes networked game logic independent of current FPS, and makes it run at a
|
||||
consistent rate. Connect handlers to *NetworkTime*'s signals to implement
|
||||
networked game logic.
|
||||
|
||||
During each frame, *NetworkTime* checks how much time has elapsed since the
|
||||
last tick loop. When more time has elapsed than a single tick's duration, the
|
||||
*network tick loop* will run:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
start
|
||||
|
||||
:before_tick_loop;
|
||||
|
||||
while (Ticks to simulate) is (>0)
|
||||
:before_tick;
|
||||
:on_tick;
|
||||
:after_tick;
|
||||
endwhile (0)
|
||||
|
||||
:after_tick_loop;
|
||||
|
||||
stop
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
The tick loop will run as long as it catches up on ticks to run. Every loop is
|
||||
limited to run at most `max_ticks_per_frame` ticks to avoid overwhelming the
|
||||
CPU.
|
||||
|
||||
To tie the network tick loop to Godot's physics process, enable
|
||||
`sync_to_physics`. This will result in the tick loop running a single tick in
|
||||
every physics update.
|
||||
|
||||
To move your game logic to the network tick loop, use the *on_tick* event:
|
||||
|
||||
```gdscript
|
||||
extends Node3D
|
||||
|
||||
@export var speed = 4.0
|
||||
|
||||
func _ready():
|
||||
NetworkTime.on_tick.connect(_tick)
|
||||
|
||||
func _tick(delta, tick):
|
||||
# Move forward
|
||||
position += basis.z * delta * speed
|
||||
```
|
||||
|
||||
> By convention, *on_tick* handlers are named *_tick*.
|
||||
|
||||
## Starting and stopping
|
||||
|
||||
By default, *NetworkTime* does not run the tick loop at all. This lets you
|
||||
control when the network tick loop, and thus the game starts and stops.
|
||||
|
||||
To start the tick loop, call the `NetworkTime.start()` coroutine. On servers,
|
||||
this will start the tick loop and return immediately. On clients, it will first
|
||||
synchronize the time to the server, start the network tick loop, and only then
|
||||
return. Use this when starting the game.
|
||||
|
||||
> Starting the tick loop before starting multiplayer is not supported.
|
||||
|
||||
To stop the tick loop, call `NetworkTime.stop()`. This will immediately stop
|
||||
the tick loop and return. Use this when the player leaves a game.
|
||||
|
||||
To get notified when a client successfully syncs their time and starts the tick
|
||||
loop, use the `NetworkTime.after_client_sync(peer_id)` signal. This is fired
|
||||
once per client, and only on the server.
|
||||
|
||||
## Pausing
|
||||
|
||||
*NetworkTime* also supports pausing the game, if needed. There's two cases
|
||||
where pauses are considered.
|
||||
|
||||
When running ( and pausing ) the game from the editor, the network tick loop
|
||||
is automatically paused. As there's currently no API to detect the editor
|
||||
pausing the game, *NetworkTime* checks if Godot's `_process` delta and actual
|
||||
delta is mismatching, and if so, considers the game paused. In some cases, this
|
||||
can result in false positives when the game simply hangs for a bit, e.g. when
|
||||
loading resources.
|
||||
|
||||
This pause detection only happens when the game is run from the editor, to
|
||||
avoid false positives in production builds.
|
||||
|
||||
The other supported case is pausing the game from the engine itself. Whenever
|
||||
`SceneTree.paused` is set to true, *NetworkTime* won't run the tick loop.
|
||||
|
||||
!!!warning
|
||||
Pausing the tick loop can cause desynchronization between peers, and could
|
||||
lead to clients fast-forwarding ticks to catch up, or time recalibrations.
|
||||
If the game is paused via SceneTree, it is recommended to pause and unpause
|
||||
at the same time on all peers.
|
||||
|
||||
## Tickrate matching
|
||||
|
||||
The idea of a shared time also implies matching tickrates. If one peer were to
|
||||
run at a higher tickrate than the rest, that peer would inevitably get ahead in
|
||||
ticks, and get out of sync. If it were to run at a lower tickrate, it would get
|
||||
behind and out of sync.
|
||||
|
||||
For games where both the server and client are built from the same project,
|
||||
this doesn't usually happen, since they share the same tickrate configuration.
|
||||
|
||||
If it does happen, by default it will be considered a configuration error, and
|
||||
a warning will be emitted:
|
||||
|
||||
```
|
||||
[WRN][@43][#1][_][netfox::NetworkTickrateHandshake] Local tickrate 24tps differs from tickrate of peer #1366785595 at 36tps! Make sure that tickrates are correctly configured in the Project settings! See netfox/Time/Tickrate.
|
||||
```
|
||||
|
||||
This behavior is configurable, with the following options available:
|
||||
|
||||
Warn
|
||||
: Emit a warning about the tickrate mismatch, but do nothing. Useful for
|
||||
development.
|
||||
|
||||
Disconnect
|
||||
: Disconnect clients with mismatching tickrates. This is enforced by the
|
||||
host.
|
||||
|
||||
Adjust
|
||||
: Adjust the client's tickrate to match the host's.
|
||||
|
||||
Signal
|
||||
: Emit a signal about the detected mismatches, so custom behavior can be
|
||||
implemented.
|
||||
|
||||
See the [settings](#settings) for the appropriate configuration.
|
||||
|
||||
## Time synchronization
|
||||
|
||||
*NetworkTime* runs a time synchronization loop on clients, in the background.
|
||||
Synchronizing time makes sure that all players have a shared idea of time and
|
||||
can exchange timing-related data.
|
||||
|
||||
The synchronization itself is handled by [NetworkTimeSynchronizer].
|
||||
|
||||
*NetworkTime* provides different kinds of time, each for different use cases.
|
||||
Each time can be accessed as ticks or seconds. Both advance after every network
|
||||
tick.
|
||||
|
||||
### Synchronized time
|
||||
|
||||
* `NetworkTime.time`
|
||||
* `NetworkTime.tick`
|
||||
|
||||
Marks the current network game time. This is continuously synchronized, making
|
||||
sure that these values are as close to each other on all peers as possible.
|
||||
|
||||
Use this whenever a notion of game time is needed.
|
||||
|
||||
### Local time
|
||||
|
||||
!!! warning
|
||||
*Deprecated since netfox v1.9.0.* Use [synchronized time] instead.
|
||||
|
||||
* `NetworkTime.local_time`
|
||||
* `NetworkTime.local_tick`
|
||||
|
||||
Marks the current time in reference to the local machine. Starts at zero when
|
||||
the network tick loop starts.
|
||||
|
||||
Useful for logic that is tied to the tick loop, but is not synchronized over
|
||||
the network. A good example is visual effects.
|
||||
|
||||
Not suitable for synchronizing data, as the local time is different at each
|
||||
player.
|
||||
|
||||
### Remote time
|
||||
|
||||
!!! warning
|
||||
*Deprecated since netfox v1.9.0.* Use [synchronized time] instead.
|
||||
|
||||
* `NetworkTime.remote_tick`
|
||||
* `NetwokrTime.remote_time`
|
||||
* `NetworkTime.remote_rtt`
|
||||
|
||||
Marks the current *estimated* time of the server. This is a regularly updated
|
||||
estimate.
|
||||
|
||||
Note that on each update, the remote time may jump forwards or even backwards.
|
||||
|
||||
The estimate is based on the measured roundtrip time ( *remote_rtt* ) and the
|
||||
assumption that the latency is exactly half of that.
|
||||
|
||||
Can be used as a base for comparisons ( e.g. latency ), but *not recommended*
|
||||
for tying game logic to it.
|
||||
|
||||
To get notified when a time synchronization happens and the remote time is
|
||||
updated, use the `NetworkTime.after_sync` signal.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are found in the Project Settings, under Netfox > Time:
|
||||
|
||||

|
||||
|
||||
*Tickrate* specifies the number of ticks every second in the network tick loop.
|
||||
|
||||
*Max Ticks Per Frame* sets the maximum number of frames to simulate per tick loop. Used to avoid freezing the game under load.
|
||||
|
||||
*Recalibrate Threshold* is the largest allowed time discrepancy in seconds. If
|
||||
the difference between the remote clock and reference clock is larger than this
|
||||
setting, the reference clock will be reset to the remote clock. See
|
||||
[NetworkTimeSynchronizer] for more details.
|
||||
|
||||
*Stall Threshold* is the amount of time in seconds that can pass between two
|
||||
frames until it is considered a stall. This is used to detect game freezes or
|
||||
OS-level pauses ( e.g. the window gets minimized ). If a stall is detected, it
|
||||
is compensated by adjusting the game clock.
|
||||
|
||||
*Sync Interval* is the resting time in seconds between sampling the remote
|
||||
clock.
|
||||
|
||||
*Sync Samples* is the number of measurements to use for time synchronization.
|
||||
This includes measuring roundtrip time and estimating clock offsets.
|
||||
|
||||
*Sync Adjust Steps* is the number of iterations to use when adjusting the
|
||||
reference clock. Larger values result in more stable clocks but slower
|
||||
convergence, while smaller values synchronize more aggressively.
|
||||
|
||||
*Sync Sample Interval* *deprecated in netfox v1.9.0*. Originally used as the
|
||||
resting time between roundtrip measurements.
|
||||
|
||||
*Sync to Physics* ensures that the network tick loop runs in Godot's physics
|
||||
process when enabled. This can be useful in cases where a lot of physics
|
||||
operations need to be done as part of the tick- or the rollback loop.
|
||||
|
||||
*Tickrate Mismatch Action* indicates what to do when a tickrate mismatch is
|
||||
detected. See [Tickrate matching](#tickrate-matching) on what the individual
|
||||
options do.
|
||||
|
||||
*Suppress Offline Peer Warning* suppresses warning when `NetworkTime.start()` is
|
||||
called with the active [multiplayer peer] being an [OfflineMultiplayerPeer]. In
|
||||
most cases, this warning means that the tick loop was unintentionally started
|
||||
before connecting to a game or hosting one. When this settings is enabled, the
|
||||
warning is not printed, instead assuming the [OfflineMultiplayerPeer] is
|
||||
intentional.
|
||||
|
||||
[NetworkTimeSynchronizer]: ./network-time-synchronizer.md
|
||||
[synchronized time]: #synchronized-time
|
||||
[multiplayer peer]: https://docs.godotengine.org/en/stable/classes/class_multiplayerapi.html#class-multiplayerapi-property-multiplayer-peer
|
||||
[OfflineMultiplayerPeer]: https://docs.godotengine.org/en/stable/classes/class_offlinemultiplayerpeer.html#class-offlinemultiplayerpeer
|
||||
@@ -0,0 +1,36 @@
|
||||
# Property paths
|
||||
|
||||
Multiple nodes have *properties* as their configurations. These are specified
|
||||
as *property paths*, which have a specific syntax.
|
||||
|
||||

|
||||
|
||||
These nodes have a *Root* property. During path resolution, this *Root* node is
|
||||
taken as base for relative paths.
|
||||
|
||||
## Syntax
|
||||
|
||||
Property paths are specified as follows:
|
||||
|
||||
```txt
|
||||
<node-path>:<property-name>
|
||||
```
|
||||
|
||||
Node path can be *empty* if it refers to a property on the *root* node.
|
||||
|
||||
If specified, node path will be interpreted relative to the *root* node. Any
|
||||
valid [NodePath] will work as expected.
|
||||
|
||||
Nested properties are also supported. Specify them by appending a colon and an
|
||||
additional property name.
|
||||
|
||||

|
||||
|
||||
With Brawler as root:
|
||||
|
||||
* `:position` refers to the Brawler's position
|
||||
* `Input:aim` refers to the Input's aim
|
||||
* `:velocity:x` refers to the Brawler's velocity's X component; this is a
|
||||
nested property
|
||||
|
||||
[NodePath]: https://docs.godotengine.org/en/stable/classes/class_nodepath.html
|
||||
@@ -0,0 +1,80 @@
|
||||
# Visibility Management
|
||||
|
||||
By default, *netfox* synchronizes all properties to all peers, broadcasting
|
||||
data. This may not always be the best approach. An example is competitive
|
||||
games. These games often features mechanics like fog of war, invisibility, or
|
||||
line of sight checks. If any of these obscures a player, other players should
|
||||
not receive information about them, to avoid the possibility of wallhacks and
|
||||
other similar cheats.
|
||||
|
||||
This is supported by the use of *visibility filters*. They provide three
|
||||
mechanisms to determine who should receive data and who shouldn't.
|
||||
|
||||
## Accessing the visibility filter
|
||||
|
||||
Both [RollbackSynchronizer] and [StateSynchronizer] supports visibility
|
||||
filtering. They expose a `visibility_filter` property that can be used to
|
||||
configure filtering.
|
||||
|
||||
!!!warning
|
||||
When using visibility filtering with [RollbackSynchronizer] nodes, make
|
||||
sure to disable input broadcast. Otherwise, peers might receive input data
|
||||
from the player, but no state data from the server, leading to nodes being
|
||||
simulated without up-to-date state data.
|
||||
|
||||
## Default visibility
|
||||
|
||||
If there's no settings configured, the visibility filter falls back to the
|
||||
`default_visibility`. By default it is `true`, meaning it will broadcast data
|
||||
to all peers.
|
||||
|
||||
## Per-peer override
|
||||
|
||||
Visibility can also be set individually for each peer. This overrides the
|
||||
default visibility for the given peer.
|
||||
|
||||
An override may be `true`, `false`, or not set. An override to `true` means
|
||||
that the peer will be visible regardless of the default visibility. An override
|
||||
to `false` means that the peer will not be visible regardless of the default
|
||||
visibility. An unset override means it will fall back to the
|
||||
`default_visibility`.
|
||||
|
||||
## Filter callbacks
|
||||
|
||||
Callbacks can also be registered, to filter peers dynamically. These filters
|
||||
run before the per-peer overrides. If any of the filters reject the peer, it
|
||||
will not receive data.
|
||||
|
||||
These callbacks receive the peer ID, and return a boolean:
|
||||
|
||||
```gd
|
||||
filter.add_visibility_filter(func(peer: int):
|
||||
# Forbidden trick to halve your bandwidth :P
|
||||
return (peer % 2) == 0
|
||||
)
|
||||
```
|
||||
|
||||
## Update modes
|
||||
|
||||
Visibility filters keep an internal list of visible peers. To save on compute,
|
||||
this list is only updated on certain configurable events. This is exposed as
|
||||
its `update_mode` property, which can take on the following values:
|
||||
|
||||
Never
|
||||
: Only update visibility when manually triggered using `update_visibility()`
|
||||
|
||||
On peer
|
||||
: Only update when a peer joins or leaves
|
||||
|
||||
Per tick loop
|
||||
: Update visibility before each tick loop
|
||||
|
||||
Per tick
|
||||
: Update visibility before each network tick
|
||||
|
||||
Per rollback tick
|
||||
: Update visibility *after* each rollback tick
|
||||
|
||||
|
||||
[RollbackSynchronizer]: ../nodes/rollback-synchronizer.md
|
||||
[StateSynchronizer]: ../nodes/state-synchronizer.md
|
||||
@@ -0,0 +1,66 @@
|
||||
# PredictiveSynchronizer
|
||||
|
||||
An un-networked version of [RollbackSynchronizer] which manages states during
|
||||
the rollback loop. Its main use case is for short lived or highly
|
||||
deterministic scenarios where using [RollbackSynchronizer] isn't practical or
|
||||
necessary.
|
||||
|
||||
## Key Differences from RollbackSynchronizer
|
||||
|
||||
Same same, but different.
|
||||
|
||||
- **No networking** - Operates entirely locally
|
||||
- **No input properties** - Only manages state properties
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
Add *PredictiveSynchronizer* as a child to your target node and configure:
|
||||
|
||||

|
||||
|
||||
### Root Node
|
||||
|
||||
The *Root* property specifies the root node for resolving state properties.
|
||||
Following the same pattern as [RollbackSynchronizer], it's recommended to add
|
||||
*PredictiveSynchronizer* under its target node, making the parent the root.
|
||||
|
||||
### State Properties
|
||||
|
||||
*State properties* are recorded for each tick and restored during rollback,
|
||||
just like in [RollbackSynchronizer]. The key difference is that these states
|
||||
are only managed locally - they're never transmitted across the network.
|
||||
|
||||
See [Property paths] for details on specifying properties.
|
||||
|
||||
## Writing Prediction-Aware Scripts
|
||||
|
||||
*PredictiveSynchronizer* automatically discovers nodes with a
|
||||
`_rollback_tick()` method under the specified root. During rollback, it will
|
||||
call that method for each tick.
|
||||
|
||||
Implement `_rollback_tick()` in your scripts:
|
||||
|
||||
```gdscript
|
||||
extends ShapeCast3D
|
||||
|
||||
@export var projectile_speed: float = 50.0
|
||||
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool):
|
||||
shape_cast.force_shapecast_update()
|
||||
|
||||
if is_colliding():
|
||||
handle_collision()
|
||||
|
||||
global_position += transform.basis.z.normalized() * projectile_speed
|
||||
```
|
||||
|
||||
!!!warning
|
||||
Both *PredictiveSynchronizer* and *RollbackSynchronizer* use the same
|
||||
callback method. They are intended to manage separate nodes - having the
|
||||
same node be managed both by *RollbackSynchronizer* and
|
||||
*PredictiveSynchronizer* is not supported, and may lead to janky behavior.
|
||||
|
||||
[RollbackSynchronizer]: ./rollback-synchronizer.md
|
||||
[Property paths]: ../guides/property-paths.md
|
||||
@@ -0,0 +1,166 @@
|
||||
# RewindableAction
|
||||
|
||||
!!!warning
|
||||
RewindableActions are *experimental*, meaning the API may change in
|
||||
breaking ways, and may be less stable than other features.
|
||||
|
||||
Once the class matures and finds its final form, the *experimental* mark
|
||||
will be removed. Feedback is welcome in the meanwhile!
|
||||
|
||||
Synchronizes events that happen over the network, by letting peers predict the
|
||||
event happening, and then adjusting the game based on the host's response.
|
||||
|
||||
For example, *RewindableActions* could be use to synchronize gun shots
|
||||
implemented as part of the rollback tick loop. This is implemented in the
|
||||
[rollback-fps] example.
|
||||
|
||||
## Using RewindableActions
|
||||
|
||||
To use *RewindableActions*, add them as nodes to your scenes. Once that's done,
|
||||
grab a reference to them from your scripts as you would for any other node -
|
||||
e.g. by using its NodePath, or by @export-ing it as a variable:
|
||||
|
||||
```gdscript
|
||||
@onready var rewindable_action := $RewindableAction as RewindableAction
|
||||
@export var rewindable_action: RewindableAction
|
||||
```
|
||||
|
||||
### Predicting events
|
||||
|
||||
All peers ( both hosts and clients ) should run the same simulation in their
|
||||
`_rollback_tick` implementations. During the rollback tick, peers should
|
||||
determine whether they think an event happens by calling
|
||||
`RewindableAction.set_active()` - e.g. if they think the gun was fired they should
|
||||
call `RewindableAction.set_active(true)`, otherwise call
|
||||
`RewindableAction.set_active(false)`.
|
||||
|
||||
The *RewindableAction* will keep track of the changes caused by `set_active()`.
|
||||
Clients ( i.e. peers *not* owning the *RewindableAction* ) will wait for the
|
||||
host ( i.e. peer owning the *RewindableAction* ) to broadcast the ground truth,
|
||||
noting when did the event happen, and when did it not.
|
||||
|
||||
!!!note
|
||||
Not calling `set_active()` on a specific tick means no prediction for that tick
|
||||
will be synchronized, potentially leading to desyncs.
|
||||
|
||||
### Performing events
|
||||
|
||||
With the above, *RewindableAction* will synchronize *when* something happens,
|
||||
but *what* should happen is up to the game logic.
|
||||
|
||||
For each rollback tick, to figure out what should happen, the `get_status()`
|
||||
method will return one of the following values:
|
||||
|
||||
`RewindableAction.INACTIVE`
|
||||
: The event hasn't happened yet.
|
||||
|
||||
`RewindableAction.ACTIVE`
|
||||
: The event has already happened, and this is not the first time its logic
|
||||
will run.
|
||||
|
||||
`RewindableAction.CONFIRMING`
|
||||
: The event was just set to active in this tick.
|
||||
|
||||
`RewindableAction.CANCELLING`
|
||||
: The event was just set to inactive in this tick.
|
||||
|
||||
See the following graph for a better understanding of how a *RewindableAction*
|
||||
transitions from one state to another:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
[*] --> INACTIVE
|
||||
INACTIVE --> CONFIRMING: set_active(true)
|
||||
INACTIVE --> CONFIRMING: Host confirms
|
||||
CONFIRMING --> ACTIVE: Tick is ran again
|
||||
ACTIVE --> CANCELLING: set_active(false)
|
||||
ACTIVE --> CANCELLING: Host declines
|
||||
CANCELLING --> INACTIVE: Tick is ran again
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
Keeping with the gunfire example, if the status is `ACTIVE` or `CONFIRMING`,
|
||||
make sure to perform the firing logic - e.g. do a hitscan, and decrease the
|
||||
health of the player hit. In other words, make sure to update the *game state*.
|
||||
|
||||
If the state is `CONFIRMING`, implement logic that may spawn other objects (
|
||||
e.g. a bullet hole when hitting a wall ).
|
||||
|
||||
If the state is `CANCELLING`, undo any logic ran in `CONFIRMING`.
|
||||
|
||||
Usually no extra code is necessary for `INACTIVE`, since the game state update
|
||||
can simply be skipped, and other related code is ran in
|
||||
`CONFIRMING`/`CANCELLING`.
|
||||
|
||||
### Reacting to status changes
|
||||
|
||||
Without [mutations], nodes are not always re-ran for every tick during
|
||||
rollback. To make sure that rollback code is ran when the *RewindableAction*'s
|
||||
status changes, use `mutate()` to register the appropriate nodes to be
|
||||
*mutated* if the action's status changes, e.g.:
|
||||
|
||||
```gdscript
|
||||
@onready var rewindable_action := $RewindableAction as RewindableAction
|
||||
|
||||
func _ready():
|
||||
rewindable_action.mutate(self)
|
||||
|
||||
func _rollback_tick(delta, tick, is_fresh):
|
||||
rewindable_action.set_active(...)
|
||||
```
|
||||
|
||||
### Remembering things between tick loops
|
||||
|
||||
*RewindableActions* also provide the concept of *context*. This is any
|
||||
arbitrary value that the *RewindableAction* will remember for the given tick,
|
||||
even throughout tick loops.
|
||||
|
||||
The *context* value can be set and retrieved by the user.
|
||||
|
||||
For example, *context* can be used for weapons to remember the projectile they
|
||||
have spawned. If the action transitions to `CANCELLING`, the *context* can be
|
||||
used to remember which projectile was spawned in that tick, and in turn, which
|
||||
projectile needs to be destroyed.
|
||||
|
||||
Use `has_context()` to check if there's any context set, `get_context()` to
|
||||
retrieve it, `set_context()` to update the *context* value, and
|
||||
`erase_context()` to forget it.
|
||||
|
||||
## Handling visuals and effects
|
||||
|
||||
Performing events ( e.g. a gunshot ) often includes not just updates to the
|
||||
game state ( like decreasing health ), but also visual- and audio effects to
|
||||
communicate what's happening to the player.
|
||||
|
||||
Since a rollback tick loop may run multiple ticks in a single frame, simply
|
||||
playing sounds and other effects from the rollback tick loop can end up
|
||||
spamming particles and playing the same sound effects many times on the same
|
||||
frame.
|
||||
|
||||
Instead, one approach would be to check whether the event has happened at the
|
||||
end of each tick loop, and if so, play the appropriate sounds and run the
|
||||
appropriate effects.
|
||||
|
||||
Use `has_confirmed()` to check if the action has been confirmed since the
|
||||
beginning of the last tick loop ( i.e. had the `CONFIRMING` status ), and
|
||||
`has_cancelled()` to check if the action has been cancelled.
|
||||
|
||||
For example:
|
||||
|
||||
```gdscript
|
||||
@onready var fire_action := $"Fire Action" as RewindableAction
|
||||
|
||||
func _ready():
|
||||
NetworkTime.after_tick_loop.connect(_after_loop)
|
||||
# ...
|
||||
|
||||
func _after_loop():
|
||||
if fire_action.has_confirmed():
|
||||
sound.play()
|
||||
```
|
||||
|
||||
[rollback tick loop]: ../guides/network-rollback.md
|
||||
[rollback-fps]: https://github.com/foxssake/netfox/tree/main/examples/rollback-fps
|
||||
[mutations]: ../tutorials/modifying-objects-during-rollback.md
|
||||
@@ -0,0 +1,151 @@
|
||||
# RollbackSynchronizer
|
||||
|
||||
Manages state during the network rollback loop by hooking into
|
||||
[NetworkRollback] events. Simulates nodes as required during rollback.
|
||||
|
||||
To read more on best practices, see [Rollback caveats].
|
||||
|
||||
## Configuring state and input
|
||||
|
||||
To use *RollbackSynchronizer*, add it as a child to the target node, specify
|
||||
the root node, and configure which properties to manage:
|
||||
|
||||

|
||||
|
||||
*Root* specifies the root node for resolving state and input properties. Best
|
||||
practice dictates to add *RollbackSynchronizer* under its target, so *Root*
|
||||
will most often be the *RollbackSynchronizer*'s parent node.
|
||||
|
||||
*State properties* are recorded for each tick and restored during rollback. For
|
||||
state, the server is the ultimate authority. Make sure that nodes containing
|
||||
state properties are owned by the server.
|
||||
|
||||
*Full state interval* specifies how many ticks to wait between full states. If
|
||||
diff states are enabled, full states are only sent at specific intervals, to
|
||||
make sure that peers always have the correct state data. *Only considered if
|
||||
diff states are enabled.*
|
||||
|
||||
*Diff ack interval* specifies how many ticks to wait between acknowledging diff
|
||||
states. Setting this to lower non-zero values may result in more bandwidth
|
||||
savings on non-changing properties, but this can be outweighed by the increased
|
||||
number of ack messages. *Only considered if diff states are enabled.*
|
||||
|
||||
See [diff states](#diff-states) for more on how the above two settings are
|
||||
used.
|
||||
|
||||
*Input properties* are gathered for each player and sent to the server to use
|
||||
for simulation. Make sure that nodes containing input properties are owned by
|
||||
their respective players.
|
||||
|
||||
See [Property paths] on how to specify properties.
|
||||
|
||||
*Enable input broadcast* toggles whether input properties are broadcast to all
|
||||
peers, or only to the server. The default is *true* to support legacy
|
||||
behaviour. It is recommended to turn this off to lower bandwidth and lessen the
|
||||
attack surface for cheating.
|
||||
|
||||
!!!warning
|
||||
It is not recommended to have both state and input properties on the same
|
||||
node. Since nodes with state belong to the server, and nodes with input
|
||||
belong to the player, it is difficult to separate ownership on the same
|
||||
node.
|
||||
|
||||
## Writing rollback-aware scripts
|
||||
|
||||
During setup, *RollbackSynchronizer* finds all the rollback-aware nodes under
|
||||
the specified *root*. During rollback, it will call all the rollback-aware
|
||||
nodes to simulate new state.
|
||||
|
||||
To learn about rollback-awareness, see [NetworkRollback].
|
||||
|
||||
In short, implement `_rollback_tick` in your scripts:
|
||||
|
||||
```gdscript
|
||||
extends CharacterBody3D
|
||||
|
||||
@export var speed = 4.0
|
||||
@export var input: PlayerInput
|
||||
|
||||
func _rollback_tick(delta, tick, is_fresh):
|
||||
velocity = input.movement.normalized() * speed
|
||||
|
||||
velocity *= NetworkTime.physics_factor
|
||||
move_and_slide()
|
||||
velocity /= NetworkTime.physics_factor
|
||||
```
|
||||
|
||||
Note the usage of `physics_factor` - this is explained in [Rollback caveats].
|
||||
|
||||
|
||||
## Single fire events
|
||||
|
||||
The first time a rollback tick is processed, the `is_fresh` parameter is set to
|
||||
`true`. This can be used to trigger animations or sounds without them being
|
||||
repeated each rollback event.
|
||||
|
||||
For example to improve the client side experience a spell or weapon can play
|
||||
its activating sounds and animation immediately and then proceed to complete
|
||||
the action once server confirmation is received.
|
||||
|
||||
## Changing configuration
|
||||
|
||||
*RollbackSynchronizer* has to do some setup work whenever the state or the
|
||||
input properties change.
|
||||
|
||||
By default, this work is done upon instantiation. If you need to change state
|
||||
or input properties during runtime, make sure to call `process_settings()`,
|
||||
otherwise *RollbackSynchronizer* won't apply the changes.
|
||||
|
||||
!!! warning
|
||||
While changing configuration after instantiation is possible, it is not
|
||||
recommended. You may get away with it if the configuration change happens in a
|
||||
few ticks after instantiation. For longer periods, experiment at your own risk.
|
||||
|
||||
## Changing ownership
|
||||
|
||||
The setup work above is also needed whenever the multiplayer authority changes
|
||||
of any of the nodes that have a state- or input property.
|
||||
|
||||
Changing authority during gameplay is supported. Make sure to call
|
||||
`process_authority()` on all peers at the same time, to ensure they're on sync
|
||||
about ownership.
|
||||
|
||||
This method is called automatically during instantiation and whenever
|
||||
`process_settings()` is called.
|
||||
|
||||
---
|
||||
|
||||
When *only* multiplayer authority changes, call `process_authority()`. When the
|
||||
configured state- or input properties change ( i.e. different properties need
|
||||
to be synced ), call `process_settings()`.
|
||||
|
||||
## Diff states
|
||||
|
||||
When diff states are enabled in the [rollback settings], netfox will attempt to
|
||||
save bandwidth by only sending state properties that have changed.
|
||||
|
||||
These changes are always based on a tick that the receiving peer has confirmed
|
||||
it already has. Basically we don't want to send changes compared to a tick that
|
||||
the peer has no knowledge about.
|
||||
|
||||
Peers notify the host of which ticks they know about by *acknowledging* ( or
|
||||
ack'ing ) ticks. This acknowledging has two flavors.
|
||||
|
||||
The first flavor is *full states*. These states contain all the state data,
|
||||
regardless of what changed and what has stayed the same. These ensure that
|
||||
peers have all the state data for a given tick. Once a full state is received,
|
||||
the receiving peer acknowledges that tick over a reliable channel.
|
||||
|
||||
The second flavor is *diff states*. Peers may also acknowledge ticks after
|
||||
receiving a diff state, meaning that they have reconstructed the given state
|
||||
from a known earlier state and the diff state received. These are acknowledged
|
||||
over an unreliable channel. By using an unreliable channel, we can acknowledge
|
||||
diff states more often without causing any hiccups in network traffic.
|
||||
|
||||
When diff states are disabled, netfox will always send full state data for all
|
||||
ticks.
|
||||
|
||||
[Rollback caveats]: ../tutorials/rollback-caveats.md
|
||||
[NetworkRollback]: ../guides/network-rollback.md
|
||||
[Property paths]: ../guides/property-paths.md
|
||||
[rollback settings]: ../guides/network-rollback.md#settings
|
||||
@@ -0,0 +1,74 @@
|
||||
# StateSynchronizer
|
||||
|
||||
Synchronizes state from the node's authority to other peers.
|
||||
|
||||
Similar to Godot's [MultiplayerSynchronizer], but is tied to the [network tick
|
||||
loop]. Works well with [TickInterpolator].
|
||||
|
||||
One way to use this node is to synchronize logic that runs only on the server,
|
||||
for example NPC's in your games. The NPC's are controlled fully by the server,
|
||||
and their state is synchronized to the clients by the *StateSynchronizer*
|
||||
nodes.
|
||||
|
||||
## Configuring state
|
||||
|
||||
To use *StateSynchronizer*, add it as a child to the target node, specify the
|
||||
root node, and configure which properties to synchronize:
|
||||
|
||||

|
||||
|
||||
*Root* specifies the root node for resolving properties. Best practice dictates
|
||||
to add *StateSynchronizer* under its target, so *Root* will most often be the
|
||||
*StateSynchronizer*'s parent node.
|
||||
|
||||
*Properties* are recorded for each tick on the node's authority ( usually the
|
||||
server ), and broadcast to other peers. These are analogous to
|
||||
[RollbackSynchronizer]'s *state properties*.
|
||||
|
||||
See [Property paths] on how to specify properties.
|
||||
|
||||
## Changing configuration
|
||||
|
||||
*StateSynchronizer* has to do some setup work whenever the state or the
|
||||
input properties change.
|
||||
|
||||
By default, this work is done upon instantiation. If you need to change
|
||||
properties during runtime, make sure to call `process_settings()`, otherwise
|
||||
*StateSynchronizer* won't apply the changes.
|
||||
|
||||
You can change the node's authority without calling `process_settings()` again.
|
||||
Make sure that the authority is changed the same way on all peers, to avoid
|
||||
discrepancies.
|
||||
|
||||
## When to use StateSynchronizer and MultiplayerSynchronizer
|
||||
|
||||
Part of the design philosophy of netfox is to build *on top of* Godot's
|
||||
networking tools, instead of *replacing* them.
|
||||
|
||||
Both [MultiplayerSynchronizer] and StateSynchronizer can be used to synchronize
|
||||
state from authority to the rest of the peers.
|
||||
|
||||
[MultiplayerSynchronizer] uses its own timer, and is independent of netfox's
|
||||
[network tick loop]. It can also do delta updates, and manage visibility per
|
||||
peer. Since it is not tied to netfox's tick loop, it does not work with
|
||||
[TickInterpolator].
|
||||
|
||||
StateSynchronizer records all the properties specified and broadcasts them
|
||||
as-is to all peers. This does not include visiblity or delta updates. The
|
||||
broadcast happens on every network tick. This node is explicitly designed to
|
||||
work with [TickInterpolator].
|
||||
|
||||
---
|
||||
|
||||
You can use StateSynchronizer for properties that you want to be interpolated,
|
||||
like position, rotation, or any other visual properties.
|
||||
|
||||
You can use [MultiplayerSynchronizer] for properties that either don't need
|
||||
interpolation ( e.g. a unit's HP ), or specifically need one of
|
||||
[MultiplayerSynchronizer]'s features.
|
||||
|
||||
[MultiplayerSynchronizer]: https://docs.godotengine.org/en/stable/classes/class_multiplayersynchronizer.html
|
||||
[network tick loop]: ../guides/network-time.md
|
||||
[TickInterpolator]: ./tick-interpolator.md
|
||||
[RollbackSynchronizer]: ./rollback-synchronizer.md
|
||||
[Property paths]: ../guides/property-paths.md
|
||||
@@ -0,0 +1,62 @@
|
||||
# TickInterpolator
|
||||
|
||||
Interpolates between network ticks to smooth out motion.
|
||||
|
||||
Uses [Interpolators] under the hood to support various data types. To read more
|
||||
on best practices, see [Interpolation caveats].
|
||||
|
||||
## Configuring interpolation
|
||||
|
||||
To use *TickInterpolator*, add it as a child to the target node, specify the
|
||||
root node, and configure which properties to interpolate:
|
||||
|
||||

|
||||
|
||||
*Root* specifies the root node for resolving *Properties*. Best practice
|
||||
dictates to add *TickInterpolator* under its target, so *Root* will most often
|
||||
be the *TickInterpolator*'s parent node.
|
||||
|
||||
*Properties* specify which properties to interpolate. See [Property paths] on
|
||||
how to specify these values.
|
||||
|
||||
*Record First State* will make *TickInterpolator* take a snapshot when the Node
|
||||
is instantiated. This snapshot will be used for interpolation, instead of
|
||||
waiting for the next network tick. Useful for objects which start moving
|
||||
instantly upon entering the scene tree, like projectiles.
|
||||
|
||||
*Enable Recording* toggles automatic state recording. When enabled,
|
||||
*TickInterpolator* will take a new snapshot after each network tick loop and
|
||||
interpolate towards that. Disabling this will require you to manually call
|
||||
`push_state()` whenever the *properties* are updated.
|
||||
|
||||
## Sudden changes
|
||||
|
||||
When a node makes a sudden change, like teleporting from one place to another,
|
||||
interpolation may not be desired.
|
||||
|
||||
Call `teleport()` in these cases to avoid interpolation and just jump to the
|
||||
current state. Interpolation will resume after the current state.
|
||||
|
||||
Example:
|
||||
|
||||
```gdscript
|
||||
func _tick(tick, delta):
|
||||
# Respawn after a while
|
||||
if _tick == respawn_tick:
|
||||
# Jump to spawn point, without interpolation
|
||||
position = spawn_position
|
||||
$TickInterpolator.teleport()
|
||||
```
|
||||
|
||||
## Changing configuration
|
||||
|
||||
*TickInterpolator* has to do some setup work whenever the interpolated
|
||||
properties change, e.g. when a new property needs to be interpolated.
|
||||
|
||||
By default, this work is done upon instantiation. If you need to change
|
||||
interpolated properties during runtime, make sure to call `process_settings()`,
|
||||
otherwise *TickInterpolator* won't apply the changes.
|
||||
|
||||
[Interpolators]: ../guides/interpolators.md
|
||||
[Interpolation caveats]: ../tutorials/interpolation-caveats.md
|
||||
[Property paths]: ../guides/property-paths.md
|
||||
@@ -0,0 +1,119 @@
|
||||
# Configuring properties from code
|
||||
|
||||
In netfox, there are multiple nodes that accept [property paths] as their
|
||||
configuration, for various purposes. These can be configured as lists of
|
||||
strings in the editor.
|
||||
|
||||
In bigger projects, with many scenes and deeper class trees, manually
|
||||
configuring property paths may be tedious and unscaleable. Potentially, there
|
||||
may be cases where these properties are only known at runtime, not when working
|
||||
in the Editor.
|
||||
|
||||
There are solutions for both cases.
|
||||
|
||||
## Adding properties from code
|
||||
|
||||
Properties can be added at run-time with the following methods:
|
||||
|
||||
* `TickInterpolator::add_property(node, property)`
|
||||
* `StateSynchronizer::add_state(node, property)`
|
||||
* `RollbackSynchronizer::add_state(node, property)`
|
||||
* `RollbackSynchronizer::add_input(node, property)`
|
||||
|
||||
*node* is a reference to a node - it may be a *string* or a *[NodePath]*
|
||||
pointing to an existing node, or a *[Node]* instance. When using paths, the
|
||||
path itself is considered relative to the configured *root* node.
|
||||
|
||||
After calling any of the methods above, calling `process_settings()` is not
|
||||
necessary - it will be called automatically.
|
||||
|
||||
!!! warning
|
||||
The same as with `process_settings()`, configuration changes are not
|
||||
synchronized automatically! You, the developer, must ensure that
|
||||
configuration changes happen on all peers, at the same time.
|
||||
|
||||
Changing state- and input property configurations is not recommended during
|
||||
gameplay.
|
||||
|
||||
## Adding properties automatically, in the editor
|
||||
|
||||
You can ensure that certain properties are added to netfox's nodes'
|
||||
configuration by making your class a `@tool` script, and implementing the
|
||||
following methods:
|
||||
|
||||
* TickInterpolator: `_get_interpolated_properties()`
|
||||
* StateSynchronizer: `_get_synchronized_state_properties()`
|
||||
* RollbackSynchronizer:
|
||||
* `_get_rollback_state_properties()` for state
|
||||
* `_get_rollback_input_properties()` for input
|
||||
|
||||
These must return an array, with each element being a string, or a two-element
|
||||
array.
|
||||
|
||||
Strings are interpreted as property names.
|
||||
|
||||
Arrays are interpreted as node-property pairs. Similarly to the `add_*`
|
||||
methods, the *node* may be a string, a [NodePath], or an actual [Node]
|
||||
instance. When using strings or [NodePath]s, keep in mind that the path is
|
||||
considered *relative to the node itself, not the configured root*.
|
||||
|
||||
Each of these nodes will explore nodes under their `root` node, and call the
|
||||
above methods if implemented. The results will be added to the node
|
||||
configuration.
|
||||
|
||||
This exploration is implemented in the nodes' `_get_configuration_warnings()`
|
||||
method, which is called when the node tree changes ( i.e. nodes are added /
|
||||
removed ), and when opening the scene.
|
||||
|
||||
The exploration also runs when before saving the scene, to make sure that any
|
||||
updates are picked up.
|
||||
|
||||
!!! tip
|
||||
To make sure that the updated methods are picked up, save your scene. The
|
||||
exploration is ran before every scene save.
|
||||
|
||||
An example implementation for the above methods:
|
||||
|
||||
```gdscript
|
||||
func _get_interpolated_properties():
|
||||
# Specify a list of properties
|
||||
return ["position", "quaternion"]
|
||||
|
||||
func _get_synchronized_state_properties() -> Array:
|
||||
# Specify inherited properties and more
|
||||
return super() + [
|
||||
"health", "name",
|
||||
[weapon, "ammo"], # Specify a property on another node
|
||||
["Hand/Weapon", "ammo"] # Specify node by path
|
||||
]
|
||||
|
||||
func _get_rollback_state_properties() -> Array:
|
||||
return [
|
||||
"transform", # Specify a property on self
|
||||
[weapon, "ammo"] # Specify a property on another node
|
||||
]
|
||||
|
||||
func _get_rollback_input_propertes() -> Array:
|
||||
# Specify a list of properties
|
||||
return ["movement", "is_jumping"]
|
||||
```
|
||||
|
||||
See the [Property configuration example].
|
||||
|
||||
!!! note
|
||||
In general, it's best practice to only specify node's own properties. An
|
||||
exception is when the given node has no script attached.
|
||||
|
||||
### Caveats
|
||||
|
||||
**Node renames and removals** are not tracked. Unless fixed manually, they will
|
||||
result in invalid property warnings.
|
||||
|
||||
A workaround is to reset the node's state/input/property configuration to an
|
||||
empty array and save again. This will gather the tracked properties with the
|
||||
right node names.
|
||||
|
||||
[property paths]: ../guides/property-paths.md
|
||||
[NodePath]: https://docs.godotengine.org/en/stable/classes/class_nodepath.html
|
||||
[Node]: https://docs.godotengine.org/en/stable/classes/class_node.html
|
||||
[Property configuration example]: https://github.com/foxssake/netfox/tree/main/examples/property-configuration
|
||||
@@ -0,0 +1,171 @@
|
||||
# Input gathering tips and tricks
|
||||
|
||||
In the [Responsive player movement] tutorial, we've seen a basic example on how
|
||||
to gather input. This tutorial will elaborate on how input gathering works
|
||||
under the hood, and how that affects some common input patterns in games.
|
||||
|
||||
!!!note
|
||||
You can find the full project [in the repository].
|
||||
|
||||
## Understanding input gathering
|
||||
|
||||
To have a shared notion of time, *netfox* provides its own time synchronization
|
||||
and a *tick loop*. The *tick loop* will check how much time has passed since
|
||||
the last network tick, and will run as many ticks as needed to catch up. Most
|
||||
often this is a single tick every few frames, but in special cases it might
|
||||
need to run multiple ticks in a single loop.
|
||||
|
||||
To have input available for each tick, *RollbackSynchronizer*s record input
|
||||
after every network tick.
|
||||
|
||||
Since multiple ticks may be ran in a single tick loop, it makes no sense to
|
||||
gather input for each tick - the hardware wouldn't update, since the ticks are
|
||||
run one after the other.
|
||||
|
||||
Instead, input is gathered *before* each tick loop, and then reused for each
|
||||
tick in the loop. This explains why special measures need to be taken in some
|
||||
cases.
|
||||
|
||||
To read more about *netfox*'s *tick loop*, see the [Network tick loop].
|
||||
|
||||
## Continuous inputs
|
||||
|
||||
Consider player movement - if the player holds the button *up*, the character
|
||||
will move north, right for east, *down* for south, *left* for west. If the
|
||||
player holds two directions, the character will move diagonally.
|
||||
|
||||
Since the player needs to *hold* the buttons for movement to happen, it is
|
||||
considered a *continuous* input.
|
||||
|
||||
Checking the inputs pressed at the point of gather works:
|
||||
|
||||
```gdscript
|
||||
extends BaseNetInput
|
||||
class_name PlayerInput
|
||||
|
||||
var movement: Vector3 = Vector3.ZERO
|
||||
|
||||
func _gather():
|
||||
movement = Vector3(
|
||||
Input.get_axis("move_west", "move_east"),
|
||||
Input.get_action_strength("move_jump"),
|
||||
Input.get_axis("move_north", "move_south")
|
||||
)
|
||||
```
|
||||
|
||||
However, consider what happens if inputs change between two ticks. Let's
|
||||
visualize one such case on a timeline:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
concise "Player Input" as P
|
||||
|
||||
@P
|
||||
0 is Up
|
||||
3 is Right: Tick
|
||||
4 is Up
|
||||
6 is Right: Tick
|
||||
```
|
||||
|
||||
Even though the player alternated between pressing Up and Right, only Right was
|
||||
recorded as an input. This is gets worse considering that the player was
|
||||
pressing Up *the majority of the time*.
|
||||
|
||||
The solution is to sample player input on every `_process()` frame, and average
|
||||
the samples collected before each tick loop.
|
||||
|
||||
```gdscript
|
||||
--8<-- "examples/snippets/input-gathering-tutorial/continuous-sampled-input.gd"
|
||||
```
|
||||
|
||||
This way, every known input is taken into account.
|
||||
|
||||
This method shines the best in cases where the network tickrate is considerably
|
||||
lower than the actual FPS at which the game runs. For example, in case the
|
||||
network tick loop runs at 30 ticks per second, but the game consistently runs
|
||||
and renders at 60fps, or even more.
|
||||
|
||||
With 30tps and 60fps, we take on average two input samples per tick.
|
||||
|
||||
## One-off inputs
|
||||
|
||||
Depending on game design, there are cases where the game needs the player to
|
||||
press a button to take an action. If the button is held, the action still
|
||||
happens only once, as it was pressed only once. If the player needs to perform
|
||||
the action multiple times, they need to press the relevant button multiple
|
||||
times.
|
||||
|
||||
These are considered *one-off inputs*.
|
||||
|
||||
Godot provides methods such as [Input.is_action_just_pressed()] to check if a
|
||||
given input was just pressed. Counterintuitively, this does not work as
|
||||
expected - the method recognizes the current frame ( `_process` ) or physics
|
||||
tick ( `_physics_process` ), but not *netfox* ticks. Let's see it on a
|
||||
timeline:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
concise "Player Input" as P
|
||||
|
||||
@P
|
||||
0 is Empty
|
||||
2 is Jump: Pressed
|
||||
5 is Jump: Tick
|
||||
```
|
||||
|
||||
Even though the input was pressed on frame 2, input gathering only ran on frame
|
||||
5, by which time the input is *held*, not *just pressed*. This means, that the
|
||||
*just pressed* check will only register if the player manages to press the
|
||||
button on the exact same frame as the input gathering is running.
|
||||
|
||||
A different issue pops up when the game slows down a bit, and *netfox* needs to
|
||||
run multiple ticks in a single loop to catch up. Let's visualize this with a
|
||||
timeline, showing both the user input in real-time, and what netfox records as
|
||||
input:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
concise "Player Input" as P
|
||||
concise "Recorded Input" as R
|
||||
|
||||
@0
|
||||
P is Empty
|
||||
R is Empty
|
||||
|
||||
@3
|
||||
P is Jump
|
||||
R is Jump: Start loop
|
||||
|
||||
@4
|
||||
P is Empty
|
||||
|
||||
@6
|
||||
R is Empty: End loop
|
||||
```
|
||||
|
||||
The player pressed Jump on a single frame, which was recorded. Then, this
|
||||
single recorded input was used for each tick in the tick loop. Resulting in the
|
||||
player trying to jump for multiple ticks, even though they pressed the button
|
||||
only on a single frame.
|
||||
|
||||
To solve both of these issues, *one-off inputs* can be buffered similarly to
|
||||
*continuous inputs*. The difference is that we reset the input value after it's
|
||||
gathered - this way, the input will be true for *at most* a single tick:
|
||||
|
||||
```gdscript
|
||||
--8<-- "examples/snippets/input-gathering-tutorial/one-off-input.gd"
|
||||
```
|
||||
|
||||
!!!tip
|
||||
The same principle of using buffer variables and accumulating input samples
|
||||
can be implemented in the `_input()` callback as well.
|
||||
|
||||
|
||||
[in the repository]: https://github.com/foxssake/netfox/tree/main/examples/input-gathering
|
||||
[Responsive player movement]: ./responsive-player-movement.md
|
||||
[Network tick loop]: ../guides/network-time.md#network-tick-loop
|
||||
[Input.is_action_just_pressed()]: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-is-action-just-pressed
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Interpolation caveats
|
||||
|
||||
While netfox runs netcode at a fixed rate, the game may render frames at a
|
||||
higher, varying framerate. Interpolation smooths out the difference between
|
||||
tickrate and framerate, when using [TickInterpolator].
|
||||
|
||||
Below are some aspects that may catch users off guard.
|
||||
|
||||
### Interpolate only visuals
|
||||
|
||||
A node's state may consist of multiple properties, some of which affect its
|
||||
appearance ( e.g. position, rotation, scale ), some are only relevant to the
|
||||
simulation - e.g. most objects look the same regardless of their velocity, even
|
||||
though it's important for simulating their behavior.
|
||||
|
||||
Since interpolation matters only for the game's visuals, it's enough to
|
||||
interpolate only the properties that affect the game's visuals.
|
||||
|
||||
### Rotation vs. Quaternion vs. Transform
|
||||
|
||||
Interpolating `rotation` may lead to glitchy results when an object makes a
|
||||
full turn. This stems from the way `rotation` works - it represents the amount
|
||||
of rotation per axis, in Euler angles. Using Euler angles to interpolate
|
||||
rotations doesn't work well, as they can end up interpolating from -180 degrees
|
||||
to +180 numerically. The expected behavior would be to go from -180 to +180
|
||||
instantly, since they represent the same rotation. The same thing happens in
|
||||
animation software as well, when trying to interpolate with Euler angles.
|
||||
|
||||
What to do instead:
|
||||
|
||||
* Interpolate the whole `transform`
|
||||
* Interpolate `quaternion` - represents rotation, but better suited to
|
||||
interpolation
|
||||
|
||||
For more, see Godot docs on [3D transforms]
|
||||
|
||||
[TickInterpolator]: ../nodes/tick-interpolator.md
|
||||
[3D transforms]: https://docs.godotengine.org/en/stable/tutorials/3d/using_transforms.html
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Modifying objects during rollback
|
||||
|
||||
There are cases where two objects interact and modify each other during
|
||||
rollback. For example:
|
||||
|
||||
* Players shoving another
|
||||
* An explosion displacing objects around it
|
||||
* Two cars colliding
|
||||
* A player shooting at another - if player stats are managed as part of
|
||||
rollback
|
||||
|
||||
## Using Mutations
|
||||
|
||||
!!!warning
|
||||
Mutations are *experimental*, meaning the API may change in breaking ways,
|
||||
and may be less stable than other features.
|
||||
|
||||
Once the API matures and finds its final form, the *experimental* mark will
|
||||
be removed. Feedback is welcome in the meanwhile!
|
||||
|
||||
Mutations enable modifying objects during rollback, in a way that is taken into
|
||||
account by netfox.
|
||||
|
||||
When an object is modified during rollback, call `NetworkRollback.mutate()`,
|
||||
passing said object as an argument.
|
||||
|
||||
As a result, the changes made to the object in the current tick will be
|
||||
recorded. Since its history has changed, it will be resimulated from the point
|
||||
of change - i.e. for all ticks after the change was made.
|
||||
|
||||
!!!note
|
||||
Make sure that `mutate()` is only called on objects that need it - otherwise,
|
||||
ticks will be resimulated for objects that don't need it, resulting in worse
|
||||
performance.
|
||||
|
||||
### Example code
|
||||
|
||||
To see this in action, take a snippet from Forest Brawl:
|
||||
|
||||
```gdscript
|
||||
for brawler in _get_overlapping_brawlers():
|
||||
var diff := brawler.global_position - global_position
|
||||
var f := clampf(1.0 / (1.0 + diff.length_squared()), 0.0, 1.0)
|
||||
|
||||
var offset := Vector3(diff.x, max(0, diff.y), diff.z).normalized()
|
||||
offset *= strength_factor * strength * f * NetworkTime.ticktime
|
||||
|
||||
brawler.shove(offset)
|
||||
NetworkRollback.mutate(brawler)
|
||||
```
|
||||
|
||||
The script calculates which direction to shove the player in, and with what
|
||||
force. This is then applied by calling `shove()`.
|
||||
|
||||
Then, on the last line, these changes are saved by calling
|
||||
`NetworkRollback.mutate(brawler)`.
|
||||
|
||||
Calling `mutate()` is all that's needed to use this feature.
|
||||
|
||||
## The problem with naive implementations
|
||||
|
||||
The simplest way to implement these mechanics is to just update the affected
|
||||
object, without using mutations. For example, when one player shoves another,
|
||||
the shove direction can simply be added to the target player's position. Doing
|
||||
this will not work unfortunately.
|
||||
|
||||
Let's say that Player A is shoving Player B. With Player A being the local
|
||||
player, we have input for its actions. With Player B being a remote player, it
|
||||
won't be simulated. So even though its position was modified, this change will
|
||||
not be recorded, and will be overridden by its last *known* position.
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
concise "Player A" as PA
|
||||
concise "Player B" as PB
|
||||
|
||||
@0
|
||||
PA is Restored
|
||||
PB is Restored
|
||||
|
||||
@8
|
||||
PA is Simulated
|
||||
|
||||
@10
|
||||
PA -> PB: shove()
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
In the example above, even though Player A shoved Player B on tick 10, Player B
|
||||
is not simulated in that given tick, so it won't be recorded. Player A's shove
|
||||
is not saved to history.
|
||||
|
||||
This may partially be fixed by enabling [prediction] for players.
|
||||
|
||||
Take another case, where Player B wants to shove Player A. With Player B being
|
||||
a remote player, we only receive its input a few ticks after the fact. So we
|
||||
need to resimulate Player B from an earlier tick. In one of these earlier tick,
|
||||
Player A gets shoved.
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
concise "Player A" as PA
|
||||
concise "Player B" as PB
|
||||
|
||||
@0
|
||||
PA is Restored
|
||||
PB is Restored
|
||||
|
||||
@6
|
||||
PB is Simulated
|
||||
|
||||
@7
|
||||
PB -> PA: shove()
|
||||
|
||||
@8
|
||||
PA is Simulated
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
In this example, we've received input for Player B for tick 6 onwards. On tick
|
||||
7, Player B shoves Player A. Since we've already simulated Player A for the
|
||||
given tick, we don't need to simulate it again. This means that any changes for
|
||||
the tick will not be recorded. The shove will not be saved to history.
|
||||
|
||||
Since Player A was already simulated and recorded for this earlier tick, it
|
||||
being shoved will not be recorded.
|
||||
|
||||
In both cases, we need to use mutations to tell netfox that a given object has
|
||||
been modified ( *mutated* ), and its state history should be updated.
|
||||
|
||||
Let's try the previous example, but now with `mutate()` added:
|
||||
|
||||
```puml
|
||||
@startuml
|
||||
|
||||
concise "Player A" as PA
|
||||
concise "Player B" as PB
|
||||
|
||||
@0
|
||||
PA is Restored
|
||||
PB is Restored
|
||||
|
||||
@6
|
||||
PB is Simulated
|
||||
|
||||
@7
|
||||
PB -> PA: shove()\nmutate()
|
||||
|
||||
PA is Simulated
|
||||
|
||||
@enduml
|
||||
```
|
||||
|
||||
Player A will be resimulated from the point of shoving, and the shove itself
|
||||
will be recorded.
|
||||
|
||||
[prediction]: ./predicting-input.md
|
||||
@@ -0,0 +1,161 @@
|
||||
# Predicting input
|
||||
|
||||
Whenever clients send their inputs, it takes some time to arrive. From there,
|
||||
it also takes time for the updated game state to arrive to clients.
|
||||
|
||||
This means that the server never knows the client's *current* input, only the
|
||||
input from a few ticks ago - depending on network latency. Other clients are
|
||||
even more behind, as they also need to wait for the server to broadcast the
|
||||
updated game state.
|
||||
|
||||
Another trick *netfox* enables to hide this latency is *input prediction*.
|
||||
|
||||
## About prediction
|
||||
|
||||
By default, nodes are only simulated for ticks that we currently have enough
|
||||
information for - i.e. the *input* for the current tick. If there's no input,
|
||||
the node simply isn't simulated, as we can't know what the player intended to
|
||||
do.
|
||||
|
||||
But, what if we do know? Or what if we can make a reasonable guess?
|
||||
|
||||
For example, in driving games, it is a safe assumption that if the player was
|
||||
going full throttle three ticks ago, they are still going full throttle.
|
||||
|
||||
It is important to consider the last received input's *age*. The more time
|
||||
passes, the harder it is to reasonably predict the player's inputs.
|
||||
|
||||
*Prediction* allows users to implement similar, game-specific predictions.
|
||||
|
||||
## Implementing input prediction
|
||||
|
||||
`NetworkRollback` provides the following signal:
|
||||
|
||||
```gdscript
|
||||
signal after_prepare_tick(tick: int)
|
||||
```
|
||||
|
||||
This is emitted during rollback, *after* the input and state is applied for the
|
||||
tick about to be simulated. This is the phase where input prediction may
|
||||
happen.
|
||||
|
||||
Firstly, call `RollbackSynchronizer.is_predicting()`, to check if any
|
||||
prediction needs to be done. If none, input can be left as-is, without
|
||||
predicting.
|
||||
|
||||
You may also check if there's *any* known input for the current tick that we
|
||||
can base our prediction off of. This is done by calling
|
||||
`RollbackSynchronizer.has_input()`.
|
||||
|
||||
For the actual prediction, consider the age of the last known input. This is
|
||||
obtained by calling `RollbackSynchronizer.get_input_age()`, which will return
|
||||
the applied input's age in ticks.
|
||||
|
||||
---
|
||||
|
||||
To put all of this into practice, see the following snippet:
|
||||
|
||||
```gdscipt
|
||||
extends BaseNetInput
|
||||
|
||||
var movement: Vector3
|
||||
var confidence: float = 1.
|
||||
|
||||
@onready var _rollback_synchronizer := $"../RollbackSynchronizer" as RollbackSynchronizer
|
||||
|
||||
func _ready():
|
||||
super()
|
||||
|
||||
# Predict on `after_prepare_tick`
|
||||
NetworkRollback.after_prepare_tick.connect(_predict)
|
||||
|
||||
func _gather():
|
||||
# Gather input
|
||||
movement = Vector3(
|
||||
Input.get_axis("move_east", "move_west"),
|
||||
Input.get_action_strength("move_jump"),
|
||||
Input.get_axis("move_south", "move_north")
|
||||
)
|
||||
|
||||
func _predict(_t):
|
||||
if not _rollback_synchronizer.is_predicting():
|
||||
# Not predicting, nothing to do
|
||||
confidence = 1.
|
||||
return
|
||||
|
||||
if not _rollback_synchronizer.has_input():
|
||||
# Can't predict without input
|
||||
confidence = 0.
|
||||
return
|
||||
|
||||
# Decay input over a short time
|
||||
var decay_time := NetworkTime.seconds_to_ticks(.15)
|
||||
var input_age := _rollback_synchronizer.get_input_age()
|
||||
|
||||
# **ALWAYS** cast either side to float, otherwise the integer-integer
|
||||
# division yields either 1 or 0 confidence
|
||||
confidence = input_age / float(decay_time)
|
||||
confidence = clampf(1. - confidence, 0., 1.)
|
||||
|
||||
# Modulate input based on confidence
|
||||
movement *= confidence
|
||||
```
|
||||
|
||||
In this example, a confidence value is calculated based on the input age. This
|
||||
is then used to gradually fade out the input, as if the player slowly let go of
|
||||
the controls.
|
||||
|
||||
Make sure to consider the specifics of your game and tailor your input
|
||||
prediction strategy to the game's needs. Depending on the game, you may even
|
||||
opt out of prediction.
|
||||
|
||||
## Impossible predictions
|
||||
|
||||
In the example above, a *confidence* value of zero means that input simply
|
||||
can't be predicted currently. This usually happens when the input is too old to
|
||||
use for prediction.
|
||||
|
||||
In this case, call `NetworkRollback.ignore_prediction(target)`. This lets
|
||||
*netfox* know that the target node - usually `self` - can't be predicted. Its
|
||||
simulated state will not be recorded for the current tick.
|
||||
|
||||
To see this in practice:
|
||||
|
||||
```gdscript
|
||||
func _rollback_tick(dt, _t, _if):
|
||||
if is_zero_approx(input.confidence):
|
||||
# Can't predict, not enough confidence in input
|
||||
_rollback_synchronizer.ignore_prediction(self)
|
||||
return
|
||||
|
||||
# ... run simulation as usual ...
|
||||
```
|
||||
|
||||
If there's not enough confidence in the input, `ignore_prediction` is called,
|
||||
and we return early.
|
||||
|
||||
!!! note
|
||||
`NetworkRollback.ignore_prediction()` can be called for multiple nodes from
|
||||
the same script. This is useful in cases where a single script drives
|
||||
multiple nodes, like an FPS controller updating the whole body's position
|
||||
and the head's rotation independently.
|
||||
|
||||
## Configuring prediction
|
||||
|
||||
Running the game in its current state would result in no changes - *prediction
|
||||
is off by default*. It can be toggled separately for each
|
||||
`RollbackSynchronizer`.
|
||||
|
||||
To enable, check *Enable Prediction* in the `RollbackSynchronizer`'s
|
||||
configuration:
|
||||
|
||||

|
||||
|
||||
With this configured, `RollbackSynchronizer` will simulate all the nodes it
|
||||
manages even for ticks that *it doesn't have input for*.
|
||||
|
||||
## Example project
|
||||
|
||||
To see all of the above as one cohesive project, see the [Input prediction example].
|
||||
|
||||
[Input prediction example]: https://github.com/foxssake/netfox/tree/main/examples/input-prediction
|
||||
@@ -0,0 +1,151 @@
|
||||
# Responsive player movement
|
||||
|
||||
To compensate for latency, *netfox* implements [Client-side prediction and
|
||||
Server reconciliation]. This documentation also refers to it as rollback.
|
||||
|
||||
One use case is player movement - with CSP we don't need to wait for the
|
||||
server's response before the player's avatar can be updated.
|
||||
|
||||
## Gathering input
|
||||
|
||||
For CSP, input is separated from player state. In practice, this means that
|
||||
there's a separate node with its own script that manages input. The job of this
|
||||
script is to manage properties related to input - for example, which direction
|
||||
the player wants to move:
|
||||
|
||||
```gdscript
|
||||
extends Node
|
||||
class_name PlayerInput
|
||||
|
||||
var movement = Vector3.ZERO
|
||||
```
|
||||
|
||||
These *input properties* must be updated based on player input. Hook into the
|
||||
[network tick loop]'s *before_tick_loop* signal to update input properties:
|
||||
|
||||
```gdscript
|
||||
func _ready():
|
||||
NetworkTime.before_tick_loop.connect(_gather)
|
||||
|
||||
func _gather():
|
||||
if not is_multiplayer_authority():
|
||||
return
|
||||
|
||||
movement = Vector3(
|
||||
Input.get_axis("move_west", "move_east"),
|
||||
Input.get_action_strength("move_jump"),
|
||||
Input.get_axis("move_north", "move_south")
|
||||
)
|
||||
```
|
||||
|
||||
It is important to only update input properties if we have authority over the
|
||||
node. Otherwise we would try to change some other player's input with our own
|
||||
actions.
|
||||
|
||||
### Using BaseNetInput
|
||||
|
||||
The same can be accomplished with [BaseNetInput], with slightly less code:
|
||||
|
||||
```gdscript
|
||||
extends BaseNetInput
|
||||
class_name PlayerInput
|
||||
|
||||
var movement: Vector3 = Vector3.ZERO
|
||||
|
||||
func _gather():
|
||||
movement = Vector3(
|
||||
Input.get_axis("move_west", "move_east"),
|
||||
Input.get_action_strength("move_jump"),
|
||||
Input.get_axis("move_north", "move_south")
|
||||
)
|
||||
```
|
||||
|
||||
## Applying movement
|
||||
|
||||
The other part of the equation is *state*. Use the same approach as you would
|
||||
with your character controller, with the game logic being implemented in
|
||||
`_rollback_tick` instead of `_process` or `_physics_process`:
|
||||
|
||||
```gdscript
|
||||
extends CharacterBody3D
|
||||
|
||||
@export var speed = 4.0
|
||||
@export var input: PlayerInput
|
||||
|
||||
func _rollback_tick(delta, tick, is_fresh):
|
||||
velocity = input.movement.normalized() * speed
|
||||
|
||||
velocity *= NetworkTime.physics_factor
|
||||
move_and_slide()
|
||||
velocity /= NetworkTime.physics_factor
|
||||
```
|
||||
|
||||
Note the usage of `physics_factor` - this is explained in [the caveats].
|
||||
|
||||
## Configuring rollback
|
||||
|
||||
Create a reusable player scene with the following layout:
|
||||
|
||||

|
||||
|
||||
The root is a *CharacterBody3D* with the player controller script attached.
|
||||
|
||||
The *Input* child manages player input and has the player input script
|
||||
attached.
|
||||
|
||||
The [RollbackSynchronizer] node manages the rollback logic, making the player
|
||||
motion responsive while also keeping it [server-authoritative].
|
||||
|
||||
Configure the *RollbackSynchronizer* with the following input- and state
|
||||
properties:
|
||||
|
||||

|
||||
|
||||
## Ownership
|
||||
|
||||
Make sure that all of the player nodes are owned by the server. The exception
|
||||
is the *Input* node, which must be owned by the player who the avatar belongs
|
||||
to.
|
||||
|
||||
After setting ownerships, **make sure** to call `process_settings` on
|
||||
*RollbackSynchronizer*. This call is necessary after every ownership change.
|
||||
*RollbackSynchronizer* sorts properties based on ownership, but this sorting is
|
||||
only done in `process_settings`.
|
||||
|
||||
For example:
|
||||
|
||||
```gdscript
|
||||
@onready var rollback_synchronizer = $RollbackSynchronizer
|
||||
var peer_id = 0
|
||||
|
||||
func _ready():
|
||||
# Wait a frame so peer_id is set
|
||||
await get_tree().process_frame
|
||||
|
||||
# Set owner
|
||||
set_multiplayer_authority(1)
|
||||
input.set_multiplayer_authority(peer_id)
|
||||
rollback_synchronizer.process_settings()
|
||||
```
|
||||
|
||||
Note that `peer_id` needs to be set from the outside during spawn.
|
||||
|
||||
## Smooth motion
|
||||
|
||||
Currently, state is only updated on network ticks. If the tickrate is less than
|
||||
the FPS the game is running on, motion may get choppy.
|
||||
|
||||
Add a [TickInterpolator] node and configure it with the same *state properties*
|
||||
as the *RollbackSynchronizer*:
|
||||
|
||||

|
||||
|
||||
This will ensure smooth motion, regardless of FPS and tickrate.
|
||||
|
||||
[Client-side prediction and Server reconciliation]: https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html
|
||||
[BaseNetInput]: ../../netfox.extras/guides/base-net-input.md
|
||||
[network tick loop]: ../guides/network-time.md#network-tick-loop
|
||||
[RollbackSynchronizer]: ../nodes/rollback-synchronizer.md
|
||||
[server-authoritative]: ../concepts/authoritative-servers.md
|
||||
[the caveats]: ./rollback-caveats.md#characterbody-velocity
|
||||
[TickInterpolator]: ../nodes/tick-interpolator.md
|
||||
@@ -0,0 +1,121 @@
|
||||
# Rollback caveats
|
||||
|
||||
As with most things, rollback has some drawbacks along with its benefits.
|
||||
|
||||
### CharacterBody velocity
|
||||
|
||||
Godot's `move_and_slide()` uses the `velocity` property, which is set in
|
||||
meters/second. The method assumes a delta time based on what kind of frame is
|
||||
being run. However, it is not aware of *netfox*'s network ticks, which means
|
||||
that movement speed will be off.
|
||||
|
||||
To counteract this, multiply velocity with `NetworkTime.physics_factor`, which
|
||||
will adjust for the difference between Godot's *assumed* delta time and the
|
||||
delta time *netfox* is using.
|
||||
|
||||
If you don't want to lose your original velocity ( e.g. because it accumulates
|
||||
acceleration over time ), divide by the same property after using any built-in
|
||||
method. For example:
|
||||
|
||||
```gdscript
|
||||
# Apply movement
|
||||
velocity *= NetworkTime.physics_factor
|
||||
move_and_slide()
|
||||
velocity /= NetworkTime.physics_factor
|
||||
```
|
||||
|
||||
### CharacterBody on floor
|
||||
|
||||
CharacterBodies only update their `is_on_floor()` state only after a
|
||||
`move_and_slide()` call.
|
||||
|
||||
This means that during rollback, the position is updated, but the
|
||||
`is_on_floor()` state is not.
|
||||
|
||||
As a work-around, do a zero-velocity move before checking if the node is on the
|
||||
floor:
|
||||
|
||||
```gdscript
|
||||
extends CharacterBody3D
|
||||
|
||||
func _rollback_tick(delta, tick, is_fresh):
|
||||
# Add the gravity.
|
||||
_force_update_is_on_floor()
|
||||
if not is_on_floor():
|
||||
velocity.y -= gravity * delta
|
||||
|
||||
# ...
|
||||
|
||||
func _force_update_is_on_floor():
|
||||
var old_velocity = velocity
|
||||
velocity = Vector3.ZERO
|
||||
move_and_slide()
|
||||
velocity = old_velocity
|
||||
```
|
||||
|
||||
### Physics updates
|
||||
|
||||
Godot's physics system is updated only during `_physics_process`, while
|
||||
rollback updates the game state multiple times during a single frame.
|
||||
|
||||
Unfortunately, Godot does not support manually updating or stepping the physics
|
||||
system, [at least at the time of writing](https://github.com/godotengine/godot/pull/76462).
|
||||
This means that:
|
||||
|
||||
* Rollback and physics-based games ( RigidBodies ) don't work at the moment
|
||||
* Collision detection can work, but with workarounds
|
||||
|
||||
If there's a way to force an update for your given node type, it should work,
|
||||
i.e.
|
||||
|
||||
* ShapeCast (2D and 3D) - [force_shapecast_update()]
|
||||
* ChacacterBody (2D and 3D) - [move_and_collide()] ( which has a test only
|
||||
mode )
|
||||
|
||||
While kinematic nodes like `CharacterBody3D` can be used with rollback, physics
|
||||
queries can still cause issues (e.g.
|
||||
`PhysicsDirectSpaceState3D.intersect_shape()`). This is due to the lack of
|
||||
updates mentioned earlier. To work around this, run the following for each
|
||||
`CollisionObject` that has its position rolled back before each tick of the
|
||||
rollback loop:
|
||||
|
||||
```gdscript
|
||||
# Works for both Jolt and GodotPhysics3D.
|
||||
func _force_update_physics_transform():
|
||||
PhysicsServer3D.body_set_mode(get_rid(), PhysicsServer3D.BODY_MODE_STATIC)
|
||||
PhysicsServer3D.body_set_state(get_rid(), PhysicsServer3D.BODY_STATE_TRANSFORM, global_transform)
|
||||
PhysicsServer3D.body_set_mode(get_rid(), PhysicsServer3D.BODY_MODE_KINEMATIC)
|
||||
```
|
||||
|
||||
The above forces an update by setting the object to static, updating its
|
||||
transform, and then setting it back to its original, kinematic state.
|
||||
|
||||
Note that the above code needs to run for any kinematic object that is to be
|
||||
detected by the query and is manipulated during rollback.
|
||||
|
||||
!!!tip
|
||||
The *netfox.extras* addon provides optional support for physics simulation
|
||||
with rollback. See [Physics](../../netfox.extras/guides/physics.md)
|
||||
|
||||
### State Machines
|
||||
|
||||
State machines don't usually expect to be updated multiple times in a single
|
||||
frame or be snapped back to a previous point in time. Be cautious of:
|
||||
|
||||
- Safeguards that implement a cooldown to changes.
|
||||
- States based on values not updated in `_rollback_tick`.
|
||||
- Transitions that enforce a specific order to state changes.
|
||||
- Transitions that trigger on any state change.
|
||||
|
||||
The key concept to keep in mind is that netfox stores the configured states for
|
||||
each processed tick. When it rolls back everything is snapped back to that
|
||||
point in time and then played forward to the present in a single frame.
|
||||
|
||||
!!!tip
|
||||
The *netfox.extras* module provides an implementation of state machines
|
||||
compatible with rollback. See
|
||||
[RewindableStateMachine](../../netfox.extras/guides/rewindable-state-machine.md)
|
||||
|
||||
|
||||
[force_shapecast_update()]: https://docs.godotengine.org/en/stable/classes/class_shapecast3d.html#class-shapecast3d-method-force-shapecast-update
|
||||
[move_and_collide()]: https://docs.godotengine.org/en/stable/classes/class_physicsbody3d.html#class-physicsbody3d-method-move-and-collide
|
||||
@@ -0,0 +1,26 @@
|
||||
# Using RollbackSynchronizer without inputs
|
||||
|
||||
In certain cases, a component needs to participate in rollback, but is not
|
||||
driven by any input. One example could be more complex NPCs. These need to be
|
||||
part of the rollback tick loop, but they are not controlled by any player.
|
||||
|
||||
In these cases, you can use RollbackSynchronizer as described earlier in
|
||||
[Responsive player movement], but without the input. This means not needing an
|
||||
input node, and not configuring any input properties. State properties still
|
||||
need to be configured, and the gameplay logic must be implemented in
|
||||
`_rollback_tick()`.
|
||||
|
||||
!!!tip
|
||||
An example project featuring a simple NPC using an inputless
|
||||
RollbackSynchronizer can be found at [examples/rollback-npc].
|
||||
|
||||
Under the hood, *netfox* will simulate these inputless nodes whenever it
|
||||
encounters a tick that has no state for the inputless node. On the server, this
|
||||
means inputless nodes will be simulated only for new ticks. On clients, this
|
||||
means never being simulated, since all state is received from the server. If
|
||||
prediction is enabled, clients will simulate inputless nodes for ticks they
|
||||
don't have data from the server.
|
||||
|
||||
|
||||
[Responsive player movement]: ./responsive-player-movement.md
|
||||
[examples/rollback-npc]: https://github.com/foxssake/netfox/tree/main/examples/rollback-npc
|
||||