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
+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