Files
shawn b0c83af092 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>
2026-07-02 20:55:20 -04:00

433 lines
15 KiB
GDScript

extends Node
class_name _NetworkRollback
## Orchestrates the rollback loop.
##
## @tutorial(NetworkRollback Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-rollback/
## @tutorial(Modifying objects during rollback): https://foxssake.github.io/netfox/latest/netfox/tutorials/modifying-objects-during-rollback/
## Whether rollback is enabled.
var enabled: bool = ProjectSettings.get_setting(&"netfox/rollback/enabled", true)
## Whether diff states are enabled.
## [br][br]
## Diff states send only the state properties that have changed.
var enable_diff_states: bool = ProjectSettings.get_setting(&"netfox/rollback/enable_diff_states", true)
## How many ticks to store as history.
## [br][br]
## The larger the history limit, the further we can roll back into the past,
## thus the more latency we can manage. The drawback is, with higher history
## limit comes more history data stored, thus higher memory usage.
## [br][br]
## Rollback won't go further than this limit, regardless of inputs received.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var history_limit: int:
get:
return _history_limit
set(v):
push_error("Trying to set read-only variable history_limit")
## The earliest tick that history is retained for.
## [br][br]
## Determined by [member history_limit].
## [br][br]
## [i]read-only[/i]
var history_start: int:
get:
return maxi(0, NetworkTime.tick - history_limit)
set(v):
push_error("Trying to set read-only variable history_start")
## Offset into the past for display, in ticks.
## [br][br]
## After the rollback, we have the option to not display the absolute latest
## state of the game, but let's say the state two frames ago ( offset = 2 ).
## This can help with hiding latency, by giving more time for an up-to-date
## state to arrive before we try to display it.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var display_offset: int:
get:
return _display_offset
set(v):
push_error("Trying to set read-only variable display_offset")
## The currently displayed tick.
## [br][br]
## This is the current tick as returned by [member _NetworkTime.tick], minus
## the [member display_offset]. By configuring the [member display_offset], a
## past tick may be displayed to the player, so that updates from the server
## have slightly more time to arrive, masking latency.
## [br][br]
## [i]read-only[/i]
var display_tick: int:
get:
if enabled:
return maxi(0, NetworkTime.tick - NetworkRollback.display_offset)
else:
return NetworkTime.tick
set(v):
push_error("Trying to set read-only variable display_tick")
## Offset into the future to submit inputs, in ticks.
##
## By submitting inputs into the future, they don't happen instantly, but with
## some delay. This can help hiding latency - even if input takes some time to
## arrive, it will still be up to date, as it was timestamped into the future.
## This only works if the input delay is greater than the network latency.
## [br][br]
## In cases where the latency is greater than the input delay, this can still
## reduce the amount of resimulated frames, resulting in less compute.
## [br][br]
## [b]Note:[/b] the [code]is_fresh[/code] parameter may not work as expected
## with input latency higher than network latency.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var input_delay: int:
get:
return _input_delay
set(v):
push_error("Trying to set read-only variable input_delay")
## How many previous input frames to send along with the current one.
## [br][br]
## As inputs are sent over an unreliable channel, packets may get lost or appear
## out of order. To mitigate packet loss, we send the current and previous n
## ticks of input data. This way, even if the input for a given tick gets lost
## in transmission, the next (n-1) packets will contain the data for it.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var input_redundancy: int:
get:
return max(1, _input_redundancy)
set(v):
push_error("Trying to set read-only variable input_redundancy")
## The current [i]rollback[/i] tick.
## [br][br]
## Note that this is different from [member _NetworkTime.tick], and only makes
## sense in the context of a rollback loop.
var tick: int:
get:
return _tick
set(v):
push_error("Trying to set read-only variable tick")
## Event emitted before running the network rollback loop.
signal before_loop()
## Event emitted in preparation of each rollback tick.
## [br][br]
## Handlers should apply the state and input corresponding to the given tick.
signal on_prepare_tick(tick: int)
## Event emitted after preparing each rollback tick.
## [br][br]
## Handlers may process the prepared tick, e.g. modulating the input by its age
## to implement input prediction.
signal after_prepare_tick(tick: int)
## Event emitted to process the given rollback tick.
## [br][br]
## Handlers should check if they *need* to resimulate the given tick, and if so,
## generate the next state based on the current data ( applied in the prepare
## tick phase ).
signal on_process_tick(tick: int)
## Event emitted after the given rollback tick was processed.
signal after_process_tick(tick: int)
## Event emitted to record the given rollback tick.
## [br][br]
## By this time, the tick is advanced from the simulation, handlers should save
## their resulting states for the given tick.
signal on_record_tick(tick: int)
## Event emitted after running the network rollback loop.
signal after_loop()
# Settings
var _history_limit: int = ProjectSettings.get_setting(&"netfox/rollback/history_limit", 64)
var _display_offset: int = ProjectSettings.get_setting(&"netfox/rollback/display_offset", 0)
var _input_delay: int = ProjectSettings.get_setting(&"netfox/rollback/input_delay", 0)
var _input_redundancy: int = ProjectSettings.get_setting(&"netfox/rollback/input_redundancy", 3)
# Timing
var _tick: int = 0
var _resim_from: int
var _rollback_from: int = -1
var _rollback_to: int = -1
var _rollback_stage: String = ""
# Resim + mutations
var _is_rollback: bool = false
var _simulated_nodes: _Set = _Set.new()
var _mutated_nodes: Dictionary = {}
var _earliest_input := -1
var _earliest_state := -1
const _STAGE_BEFORE := "B"
const _STAGE_PREPARE := "P"
const _STAGE_SIMULATE := "S"
const _STAGE_RECORD := "R"
const _STAGE_AFTER := "A"
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkRollback")
## Submit the resimulation start tick for the current loop.
##
## This is used to determine the resimulation range during each loop.
func notify_resimulation_start(tick: int) -> void:
_resim_from = min(_resim_from, tick)
## Submit node for simulation.
##
## This is used mostly internally by [RollbackSynchronizer]. The idea is to
## submit each affected node while preparing the tick, and then run only the
## nodes that need to be resimulated.
func notify_simulated(node: Node) -> void:
_simulated_nodes.add(node)
## Check if node was submitted for simulation.
##
## This is used mostly internally by [RollbackSynchronizer]. The idea is to
## submit each affected node while preparing the tick, and then use
## [member is_simulated] to run only the nodes that need to be resimulated.
func is_simulated(node: Node) -> bool:
return _simulated_nodes.has(node)
## Check if a network rollback is currently active.
func is_rollback() -> bool:
return _is_rollback
## Checks if a given object is rollback-aware, i.e. has the
## [code]_rollback_tick[/code] method implemented.
##
## This is used by [RollbackSynchronizer] to see if it should simulate the
## given object during rollback.
func is_rollback_aware(what: Object) -> bool:
return what.has_method(&"_rollback_tick")
## Calls the [code]_rollback_tick[/code] method on the target, running its
## simulation for the given rollback tick.
## [br][br]
## This is used by [RollbackSynchronizer] to resimulate ticks during rollback.
## While the [code]_rollback_tick[/code] method could be called directly as
## well, this method exists to future-proof the code a bit, so the method name
## is not repeated all over the place.
## [br][br]
## [i]Note:[/i] Make sure to check if the target is rollback-aware, because if
## it's not, this method will run into an error.
func process_rollback(target: Object, delta: float, p_tick: int, is_fresh: bool) -> void:
target._rollback_tick(delta, p_tick, is_fresh)
## Marks the target object as mutated.
## [br][br]
## Mutated objects will be re-recorded for the specified tick, and resimulated
## from the given tick onwards.
## [br][br]
## For special cases, you can specify the tick when the mutation happened. Since
## it defaults to the current rollback [member tick], this parameter rarely
## needs to be specified.
## [br][br]
## Note that registering a mutation into the past will yield a warning.
## [br][br]
## @experimental: The Mutations API is experimental!
func mutate(target: Object, p_tick: int = tick) -> void:
_mutated_nodes[target] = mini(p_tick, _mutated_nodes.get(target, p_tick))
if is_rollback() and p_tick < tick:
_logger.warning(
"Trying to mutate object %s in the past, for tick %d!",
[target, p_tick]
)
## Check whether the target object was mutated in or after the given tick via
## [method mutate].
## [br][br]
## @experimental: The Mutations API is experimental!
func is_mutated(target: Object, p_tick: int = tick) -> bool:
if _mutated_nodes.has(target):
return p_tick >= _mutated_nodes.get(target)
else:
return false
## Check whether the target object was mutated specifically in the given tick
## via [method mutate].
## [br][br]
## @experimental: The Mutations API is experimental!
func is_just_mutated(target: Object, p_tick: int = tick) -> bool:
if _mutated_nodes.has(target):
return _mutated_nodes.get(target) == p_tick
else:
return false
## Register that a node has submitted its input for a specific tick
## @deprecated
func register_rollback_input_submission(_node: Node, _tick: int) -> void:
pass
## Get the latest input tick submitted for a specific node
## [br][br]
## Returns [code]-1[/code] if no input was submitted for the node, ever.
func get_latest_input_tick(node: Node) -> int:
var input_nodes := RollbackSimulationServer._get_inputs_of(node)
var reference_tick := NetworkTime.tick
return NetworkHistoryServer.get_latest_input_for(input_nodes, reference_tick)
## Check if a node has submitted input for a specific tick (or later)
func has_input_for_tick(node: Node, tick: int) -> bool:
var latest_input := get_latest_input_tick(node)
return latest_input != -1 and latest_input >= tick
## Free all input submission data for a node
## [br][br]
## Use this once the node is freed.
## @deprecated
func free_input_submission_data_for(_node: Node) -> void:
pass
func _get_rollback_method(object: Object) -> Callable:
return object._rollback_tick
func _ready():
NetfoxLogger.register_tag(_get_rollback_tag)
NetworkTime.after_tick_loop.connect(_rollback)
NetworkTime.after_tick.connect(func(_dt, tick):
NetworkHistoryServer._record_rollback_input(tick + input_delay)
NetworkSynchronizationServer._synchronize_input(tick + input_delay)
)
NetworkSynchronizationServer._on_input.connect(_handle_input)
NetworkSynchronizationServer._on_state.connect(_handle_state)
func _exit_tree():
NetfoxLogger.free_tag(_get_rollback_tag)
func _get_rollback_tag() -> String:
if _is_rollback:
return "%s@%d|%d>%d" % [_rollback_stage, _tick, _rollback_from, _rollback_to]
else:
return "_"
func _rollback() -> void:
if not enabled:
return
# Ask all rewindables to submit their earliest inputs
_resim_from = NetworkTime.tick
before_loop.emit()
# Figure out where to start rollback from
var range_source = "notif"
if _earliest_input >= 0 and _earliest_input <= _resim_from:
range_source = "earliest input"
_resim_from = _earliest_input
if _earliest_state >= 0 and _earliest_state <= _resim_from:
range_source = "latest state"
_resim_from = _earliest_state
_resim_from = mini(_resim_from, NetworkTime.tick - 1)
_logger.trace("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source])
# Only set _is_rollback *after* emitting before_loop
_is_rollback = true
_rollback_stage = _STAGE_BEFORE
# from = Earliest input amongst all rewindables
var from := _resim_from
# to = Current tick
var to := NetworkTime.tick
# Limit number of rollback ticks
if to - from > history_limit:
_logger.warning(
"Trying to run rollback for ticks %d to %d, past the history limit of %d",
[from, to, history_limit]
)
from = NetworkTime.tick - history_limit
_earliest_input = -1
_earliest_state = -1
# for tick in from .. to:
_rollback_from = from
_rollback_to = to
for tick in range(from, to):
_tick = tick
_simulated_nodes.clear()
# Prepare state
# Done individually by Rewindables ( usually Rollback Synchronizers )
# Restore input and state for tick
_rollback_stage = _STAGE_PREPARE
on_prepare_tick.emit(tick)
NetworkHistoryServer._restore_rollback_input(tick)
NetworkHistoryServer._restore_rollback_state(tick)
after_prepare_tick.emit(tick)
# Simulate rollback tick
# Method call on rewindables
# Rollback synchronizers go through each node they manage
# If current tick is in node's range, tick
# If authority: Latest input >= tick >= Latest state
# If not: Latest input >= tick >= Earliest input
_rollback_stage = _STAGE_SIMULATE
on_process_tick.emit(tick)
RollbackSimulationServer.simulate(NetworkTime.ticktime, tick)
after_process_tick.emit(tick)
# Record state for tick + 1
_rollback_stage = _STAGE_RECORD
on_record_tick.emit(tick + 1)
NetworkHistoryServer._record_rollback_state(tick + 1)
NetworkSynchronizationServer._synchronize_state(tick + 1)
# Restore display state
_rollback_stage = _STAGE_AFTER
after_loop.emit()
NetworkHistoryServer._restore_rollback_state(display_tick)
RollbackSimulationServer._trim_ticks_simulated(history_start)
# Cleanup
_mutated_nodes.clear()
_is_rollback = false
func _handle_input(snapshot: _Snapshot):
if snapshot.is_empty():
return
if _earliest_input < 0 or snapshot.tick < _earliest_input:
_logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick])
_earliest_input = snapshot.tick
else:
_logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input])
func _handle_state(snapshot: _Snapshot):
if snapshot.is_empty():
return
if _earliest_state < 0 or snapshot.tick < _earliest_state:
_logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, snapshot.tick])
_earliest_state = snapshot.tick
else:
_logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, _earliest_state])
# Insight 1:
# state(x) = simulate(state(x - 1), input(x - 1))
# state(x + 1) = simulate(state(x), input(x))
# Insight 2:
# Server is authorative over all state, client over its own input, i.e.
# Server broadcasts state
# Client sends input to server
# Flow:
# Clients send in their inputs
# Server simulates frames from earliest input to current
# Server broadcasts simulated frames
# Clients receive authorative states
# Clients simulate local frames