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
@@ -0,0 +1,66 @@
# PredictiveSynchronizer
An un-networked version of [RollbackSynchronizer] which manages states during
the rollback loop. Its main use case is for short lived or highly
deterministic scenarios where using [RollbackSynchronizer] isn't practical or
necessary.
## Key Differences from RollbackSynchronizer
Same same, but different.
- **No networking** - Operates entirely locally
- **No input properties** - Only manages state properties
## Configuration
### Basic Setup
Add *PredictiveSynchronizer* as a child to your target node and configure:
![PredictiveSynchronizer configuration](../assets/predictive-synchronizer-config.png)
### Root Node
The *Root* property specifies the root node for resolving state properties.
Following the same pattern as [RollbackSynchronizer], it's recommended to add
*PredictiveSynchronizer* under its target node, making the parent the root.
### State Properties
*State properties* are recorded for each tick and restored during rollback,
just like in [RollbackSynchronizer]. The key difference is that these states
are only managed locally - they're never transmitted across the network.
See [Property paths] for details on specifying properties.
## Writing Prediction-Aware Scripts
*PredictiveSynchronizer* automatically discovers nodes with a
`_rollback_tick()` method under the specified root. During rollback, it will
call that method for each tick.
Implement `_rollback_tick()` in your scripts:
```gdscript
extends ShapeCast3D
@export var projectile_speed: float = 50.0
func _rollback_tick(delta: float, tick: int, is_fresh: bool):
shape_cast.force_shapecast_update()
if is_colliding():
handle_collision()
global_position += transform.basis.z.normalized() * projectile_speed
```
!!!warning
Both *PredictiveSynchronizer* and *RollbackSynchronizer* use the same
callback method. They are intended to manage separate nodes - having the
same node be managed both by *RollbackSynchronizer* and
*PredictiveSynchronizer* is not supported, and may lead to janky behavior.
[RollbackSynchronizer]: ./rollback-synchronizer.md
[Property paths]: ../guides/property-paths.md
+166
View File
@@ -0,0 +1,166 @@
# RewindableAction
!!!warning
RewindableActions are *experimental*, meaning the API may change in
breaking ways, and may be less stable than other features.
Once the class matures and finds its final form, the *experimental* mark
will be removed. Feedback is welcome in the meanwhile!
Synchronizes events that happen over the network, by letting peers predict the
event happening, and then adjusting the game based on the host's response.
For example, *RewindableActions* could be use to synchronize gun shots
implemented as part of the rollback tick loop. This is implemented in the
[rollback-fps] example.
## Using RewindableActions
To use *RewindableActions*, add them as nodes to your scenes. Once that's done,
grab a reference to them from your scripts as you would for any other node -
e.g. by using its NodePath, or by @export-ing it as a variable:
```gdscript
@onready var rewindable_action := $RewindableAction as RewindableAction
@export var rewindable_action: RewindableAction
```
### Predicting events
All peers ( both hosts and clients ) should run the same simulation in their
`_rollback_tick` implementations. During the rollback tick, peers should
determine whether they think an event happens by calling
`RewindableAction.set_active()` - e.g. if they think the gun was fired they should
call `RewindableAction.set_active(true)`, otherwise call
`RewindableAction.set_active(false)`.
The *RewindableAction* will keep track of the changes caused by `set_active()`.
Clients ( i.e. peers *not* owning the *RewindableAction* ) will wait for the
host ( i.e. peer owning the *RewindableAction* ) to broadcast the ground truth,
noting when did the event happen, and when did it not.
!!!note
Not calling `set_active()` on a specific tick means no prediction for that tick
will be synchronized, potentially leading to desyncs.
### Performing events
With the above, *RewindableAction* will synchronize *when* something happens,
but *what* should happen is up to the game logic.
For each rollback tick, to figure out what should happen, the `get_status()`
method will return one of the following values:
`RewindableAction.INACTIVE`
: The event hasn't happened yet.
`RewindableAction.ACTIVE`
: The event has already happened, and this is not the first time its logic
will run.
`RewindableAction.CONFIRMING`
: The event was just set to active in this tick.
`RewindableAction.CANCELLING`
: The event was just set to inactive in this tick.
See the following graph for a better understanding of how a *RewindableAction*
transitions from one state to another:
```puml
@startuml
[*] --> INACTIVE
INACTIVE --> CONFIRMING: set_active(true)
INACTIVE --> CONFIRMING: Host confirms
CONFIRMING --> ACTIVE: Tick is ran again
ACTIVE --> CANCELLING: set_active(false)
ACTIVE --> CANCELLING: Host declines
CANCELLING --> INACTIVE: Tick is ran again
@enduml
```
Keeping with the gunfire example, if the status is `ACTIVE` or `CONFIRMING`,
make sure to perform the firing logic - e.g. do a hitscan, and decrease the
health of the player hit. In other words, make sure to update the *game state*.
If the state is `CONFIRMING`, implement logic that may spawn other objects (
e.g. a bullet hole when hitting a wall ).
If the state is `CANCELLING`, undo any logic ran in `CONFIRMING`.
Usually no extra code is necessary for `INACTIVE`, since the game state update
can simply be skipped, and other related code is ran in
`CONFIRMING`/`CANCELLING`.
### Reacting to status changes
Without [mutations], nodes are not always re-ran for every tick during
rollback. To make sure that rollback code is ran when the *RewindableAction*'s
status changes, use `mutate()` to register the appropriate nodes to be
*mutated* if the action's status changes, e.g.:
```gdscript
@onready var rewindable_action := $RewindableAction as RewindableAction
func _ready():
rewindable_action.mutate(self)
func _rollback_tick(delta, tick, is_fresh):
rewindable_action.set_active(...)
```
### Remembering things between tick loops
*RewindableActions* also provide the concept of *context*. This is any
arbitrary value that the *RewindableAction* will remember for the given tick,
even throughout tick loops.
The *context* value can be set and retrieved by the user.
For example, *context* can be used for weapons to remember the projectile they
have spawned. If the action transitions to `CANCELLING`, the *context* can be
used to remember which projectile was spawned in that tick, and in turn, which
projectile needs to be destroyed.
Use `has_context()` to check if there's any context set, `get_context()` to
retrieve it, `set_context()` to update the *context* value, and
`erase_context()` to forget it.
## Handling visuals and effects
Performing events ( e.g. a gunshot ) often includes not just updates to the
game state ( like decreasing health ), but also visual- and audio effects to
communicate what's happening to the player.
Since a rollback tick loop may run multiple ticks in a single frame, simply
playing sounds and other effects from the rollback tick loop can end up
spamming particles and playing the same sound effects many times on the same
frame.
Instead, one approach would be to check whether the event has happened at the
end of each tick loop, and if so, play the appropriate sounds and run the
appropriate effects.
Use `has_confirmed()` to check if the action has been confirmed since the
beginning of the last tick loop ( i.e. had the `CONFIRMING` status ), and
`has_cancelled()` to check if the action has been cancelled.
For example:
```gdscript
@onready var fire_action := $"Fire Action" as RewindableAction
func _ready():
NetworkTime.after_tick_loop.connect(_after_loop)
# ...
func _after_loop():
if fire_action.has_confirmed():
sound.play()
```
[rollback tick loop]: ../guides/network-rollback.md
[rollback-fps]: https://github.com/foxssake/netfox/tree/main/examples/rollback-fps
[mutations]: ../tutorials/modifying-objects-during-rollback.md
+151
View File
@@ -0,0 +1,151 @@
# RollbackSynchronizer
Manages state during the network rollback loop by hooking into
[NetworkRollback] events. Simulates nodes as required during rollback.
To read more on best practices, see [Rollback caveats].
## Configuring state and input
To use *RollbackSynchronizer*, add it as a child to the target node, specify
the root node, and configure which properties to manage:
![RollbackSynchronizer configuration](../assets/rollback-synchronizer-config.png)
*Root* specifies the root node for resolving state and input properties. Best
practice dictates to add *RollbackSynchronizer* under its target, so *Root*
will most often be the *RollbackSynchronizer*'s parent node.
*State properties* are recorded for each tick and restored during rollback. For
state, the server is the ultimate authority. Make sure that nodes containing
state properties are owned by the server.
*Full state interval* specifies how many ticks to wait between full states. If
diff states are enabled, full states are only sent at specific intervals, to
make sure that peers always have the correct state data. *Only considered if
diff states are enabled.*
*Diff ack interval* specifies how many ticks to wait between acknowledging diff
states. Setting this to lower non-zero values may result in more bandwidth
savings on non-changing properties, but this can be outweighed by the increased
number of ack messages. *Only considered if diff states are enabled.*
See [diff states](#diff-states) for more on how the above two settings are
used.
*Input properties* are gathered for each player and sent to the server to use
for simulation. Make sure that nodes containing input properties are owned by
their respective players.
See [Property paths] on how to specify properties.
*Enable input broadcast* toggles whether input properties are broadcast to all
peers, or only to the server. The default is *true* to support legacy
behaviour. It is recommended to turn this off to lower bandwidth and lessen the
attack surface for cheating.
!!!warning
It is not recommended to have both state and input properties on the same
node. Since nodes with state belong to the server, and nodes with input
belong to the player, it is difficult to separate ownership on the same
node.
## Writing rollback-aware scripts
During setup, *RollbackSynchronizer* finds all the rollback-aware nodes under
the specified *root*. During rollback, it will call all the rollback-aware
nodes to simulate new state.
To learn about rollback-awareness, see [NetworkRollback].
In short, implement `_rollback_tick` in your scripts:
```gdscript
extends CharacterBody3D
@export var speed = 4.0
@export var input: PlayerInput
func _rollback_tick(delta, tick, is_fresh):
velocity = input.movement.normalized() * speed
velocity *= NetworkTime.physics_factor
move_and_slide()
velocity /= NetworkTime.physics_factor
```
Note the usage of `physics_factor` - this is explained in [Rollback caveats].
## Single fire events
The first time a rollback tick is processed, the `is_fresh` parameter is set to
`true`. This can be used to trigger animations or sounds without them being
repeated each rollback event.
For example to improve the client side experience a spell or weapon can play
its activating sounds and animation immediately and then proceed to complete
the action once server confirmation is received.
## Changing configuration
*RollbackSynchronizer* has to do some setup work whenever the state or the
input properties change.
By default, this work is done upon instantiation. If you need to change state
or input properties during runtime, make sure to call `process_settings()`,
otherwise *RollbackSynchronizer* won't apply the changes.
!!! warning
While changing configuration after instantiation is possible, it is not
recommended. You may get away with it if the configuration change happens in a
few ticks after instantiation. For longer periods, experiment at your own risk.
## Changing ownership
The setup work above is also needed whenever the multiplayer authority changes
of any of the nodes that have a state- or input property.
Changing authority during gameplay is supported. Make sure to call
`process_authority()` on all peers at the same time, to ensure they're on sync
about ownership.
This method is called automatically during instantiation and whenever
`process_settings()` is called.
---
When *only* multiplayer authority changes, call `process_authority()`. When the
configured state- or input properties change ( i.e. different properties need
to be synced ), call `process_settings()`.
## Diff states
When diff states are enabled in the [rollback settings], netfox will attempt to
save bandwidth by only sending state properties that have changed.
These changes are always based on a tick that the receiving peer has confirmed
it already has. Basically we don't want to send changes compared to a tick that
the peer has no knowledge about.
Peers notify the host of which ticks they know about by *acknowledging* ( or
ack'ing ) ticks. This acknowledging has two flavors.
The first flavor is *full states*. These states contain all the state data,
regardless of what changed and what has stayed the same. These ensure that
peers have all the state data for a given tick. Once a full state is received,
the receiving peer acknowledges that tick over a reliable channel.
The second flavor is *diff states*. Peers may also acknowledge ticks after
receiving a diff state, meaning that they have reconstructed the given state
from a known earlier state and the diff state received. These are acknowledged
over an unreliable channel. By using an unreliable channel, we can acknowledge
diff states more often without causing any hiccups in network traffic.
When diff states are disabled, netfox will always send full state data for all
ticks.
[Rollback caveats]: ../tutorials/rollback-caveats.md
[NetworkRollback]: ../guides/network-rollback.md
[Property paths]: ../guides/property-paths.md
[rollback settings]: ../guides/network-rollback.md#settings
+74
View File
@@ -0,0 +1,74 @@
# StateSynchronizer
Synchronizes state from the node's authority to other peers.
Similar to Godot's [MultiplayerSynchronizer], but is tied to the [network tick
loop]. Works well with [TickInterpolator].
One way to use this node is to synchronize logic that runs only on the server,
for example NPC's in your games. The NPC's are controlled fully by the server,
and their state is synchronized to the clients by the *StateSynchronizer*
nodes.
## Configuring state
To use *StateSynchronizer*, add it as a child to the target node, specify the
root node, and configure which properties to synchronize:
![StateSynchronizer configuration](../assets/state-synchronizer-config.png)
*Root* specifies the root node for resolving properties. Best practice dictates
to add *StateSynchronizer* under its target, so *Root* will most often be the
*StateSynchronizer*'s parent node.
*Properties* are recorded for each tick on the node's authority ( usually the
server ), and broadcast to other peers. These are analogous to
[RollbackSynchronizer]'s *state properties*.
See [Property paths] on how to specify properties.
## Changing configuration
*StateSynchronizer* has to do some setup work whenever the state or the
input properties change.
By default, this work is done upon instantiation. If you need to change
properties during runtime, make sure to call `process_settings()`, otherwise
*StateSynchronizer* won't apply the changes.
You can change the node's authority without calling `process_settings()` again.
Make sure that the authority is changed the same way on all peers, to avoid
discrepancies.
## When to use StateSynchronizer and MultiplayerSynchronizer
Part of the design philosophy of netfox is to build *on top of* Godot's
networking tools, instead of *replacing* them.
Both [MultiplayerSynchronizer] and StateSynchronizer can be used to synchronize
state from authority to the rest of the peers.
[MultiplayerSynchronizer] uses its own timer, and is independent of netfox's
[network tick loop]. It can also do delta updates, and manage visibility per
peer. Since it is not tied to netfox's tick loop, it does not work with
[TickInterpolator].
StateSynchronizer records all the properties specified and broadcasts them
as-is to all peers. This does not include visiblity or delta updates. The
broadcast happens on every network tick. This node is explicitly designed to
work with [TickInterpolator].
---
You can use StateSynchronizer for properties that you want to be interpolated,
like position, rotation, or any other visual properties.
You can use [MultiplayerSynchronizer] for properties that either don't need
interpolation ( e.g. a unit's HP ), or specifically need one of
[MultiplayerSynchronizer]'s features.
[MultiplayerSynchronizer]: https://docs.godotengine.org/en/stable/classes/class_multiplayersynchronizer.html
[network tick loop]: ../guides/network-time.md
[TickInterpolator]: ./tick-interpolator.md
[RollbackSynchronizer]: ./rollback-synchronizer.md
[Property paths]: ../guides/property-paths.md
+62
View File
@@ -0,0 +1,62 @@
# TickInterpolator
Interpolates between network ticks to smooth out motion.
Uses [Interpolators] under the hood to support various data types. To read more
on best practices, see [Interpolation caveats].
## Configuring interpolation
To use *TickInterpolator*, add it as a child to the target node, specify the
root node, and configure which properties to interpolate:
![TickInterpolator configuration](../assets/tick-interpolator-config.png)
*Root* specifies the root node for resolving *Properties*. Best practice
dictates to add *TickInterpolator* under its target, so *Root* will most often
be the *TickInterpolator*'s parent node.
*Properties* specify which properties to interpolate. See [Property paths] on
how to specify these values.
*Record First State* will make *TickInterpolator* take a snapshot when the Node
is instantiated. This snapshot will be used for interpolation, instead of
waiting for the next network tick. Useful for objects which start moving
instantly upon entering the scene tree, like projectiles.
*Enable Recording* toggles automatic state recording. When enabled,
*TickInterpolator* will take a new snapshot after each network tick loop and
interpolate towards that. Disabling this will require you to manually call
`push_state()` whenever the *properties* are updated.
## Sudden changes
When a node makes a sudden change, like teleporting from one place to another,
interpolation may not be desired.
Call `teleport()` in these cases to avoid interpolation and just jump to the
current state. Interpolation will resume after the current state.
Example:
```gdscript
func _tick(tick, delta):
# Respawn after a while
if _tick == respawn_tick:
# Jump to spawn point, without interpolation
position = spawn_position
$TickInterpolator.teleport()
```
## Changing configuration
*TickInterpolator* has to do some setup work whenever the interpolated
properties change, e.g. when a new property needs to be interpolated.
By default, this work is done upon instantiation. If you need to change
interpolated properties during runtime, make sure to call `process_settings()`,
otherwise *TickInterpolator* won't apply the changes.
[Interpolators]: ../guides/interpolators.md
[Interpolation caveats]: ../tutorials/interpolation-caveats.md
[Property paths]: ../guides/property-paths.md