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>
This commit is contained in:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

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.
+100
View File
@@ -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
+148
View File
@@ -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
![Logging settings](../assets/logging-settings.png)
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
+98
View File
@@ -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
+28
View File
@@ -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:
![NetworkEvents settings](../assets/network-events-settings.png)
*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
+69
View File
@@ -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
+191
View File
@@ -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
![Network rollback settings](../assets/network-rollback-settings.png)
*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
+169
View File
@@ -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
+257
View File
@@ -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:
![NetworkTime Settings](../assets/network-time-settings.png)
*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
+36
View File
@@ -0,0 +1,36 @@
# Property paths
Multiple nodes have *properties* as their configurations. These are specified
as *property paths*, which have a specific syntax.
![TickInterpolator configuration](../assets/tick-interpolator-config.png)
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.
![Example hierarchy](../assets/rollback-nodes.png)
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:
![PredictiveSynchronizer configuration](../assets/predictive-synchronizer-config.png)
### 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
+166
View File
@@ -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
+151
View File
@@ -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:
![RollbackSynchronizer configuration](../assets/rollback-synchronizer-config.png)
*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
+74
View File
@@ -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:
![StateSynchronizer configuration](../assets/state-synchronizer-config.png)
*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
+62
View File
@@ -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:
![TickInterpolator configuration](../assets/tick-interpolator-config.png)
*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
+161
View File
@@ -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:
![Node configuration](../assets/rollback-enable-predict.png)
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:
![Node layout](../assets/tutorial-nodes.png)
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:
![RollbackSynchronizer settings](../assets/tutorial-rollback-settings.png)
## 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*:
![TickInterpolator settings](../assets/tutorial-tick-interpolator-settings.png)
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
+121
View File
@@ -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