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

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

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

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

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