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,66 @@
|
||||
# PredictiveSynchronizer
|
||||
|
||||
An un-networked version of [RollbackSynchronizer] which manages states during
|
||||
the rollback loop. Its main use case is for short lived or highly
|
||||
deterministic scenarios where using [RollbackSynchronizer] isn't practical or
|
||||
necessary.
|
||||
|
||||
## Key Differences from RollbackSynchronizer
|
||||
|
||||
Same same, but different.
|
||||
|
||||
- **No networking** - Operates entirely locally
|
||||
- **No input properties** - Only manages state properties
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
Add *PredictiveSynchronizer* as a child to your target node and configure:
|
||||
|
||||

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

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

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

|
||||
|
||||
*Root* specifies the root node for resolving *Properties*. Best practice
|
||||
dictates to add *TickInterpolator* under its target, so *Root* will most often
|
||||
be the *TickInterpolator*'s parent node.
|
||||
|
||||
*Properties* specify which properties to interpolate. See [Property paths] on
|
||||
how to specify these values.
|
||||
|
||||
*Record First State* will make *TickInterpolator* take a snapshot when the Node
|
||||
is instantiated. This snapshot will be used for interpolation, instead of
|
||||
waiting for the next network tick. Useful for objects which start moving
|
||||
instantly upon entering the scene tree, like projectiles.
|
||||
|
||||
*Enable Recording* toggles automatic state recording. When enabled,
|
||||
*TickInterpolator* will take a new snapshot after each network tick loop and
|
||||
interpolate towards that. Disabling this will require you to manually call
|
||||
`push_state()` whenever the *properties* are updated.
|
||||
|
||||
## Sudden changes
|
||||
|
||||
When a node makes a sudden change, like teleporting from one place to another,
|
||||
interpolation may not be desired.
|
||||
|
||||
Call `teleport()` in these cases to avoid interpolation and just jump to the
|
||||
current state. Interpolation will resume after the current state.
|
||||
|
||||
Example:
|
||||
|
||||
```gdscript
|
||||
func _tick(tick, delta):
|
||||
# Respawn after a while
|
||||
if _tick == respawn_tick:
|
||||
# Jump to spawn point, without interpolation
|
||||
position = spawn_position
|
||||
$TickInterpolator.teleport()
|
||||
```
|
||||
|
||||
## Changing configuration
|
||||
|
||||
*TickInterpolator* has to do some setup work whenever the interpolated
|
||||
properties change, e.g. when a new property needs to be interpolated.
|
||||
|
||||
By default, this work is done upon instantiation. If you need to change
|
||||
interpolated properties during runtime, make sure to call `process_settings()`,
|
||||
otherwise *TickInterpolator* won't apply the changes.
|
||||
|
||||
[Interpolators]: ../guides/interpolators.md
|
||||
[Interpolation caveats]: ../tutorials/interpolation-caveats.md
|
||||
[Property paths]: ../guides/property-paths.md
|
||||
Reference in New Issue
Block a user