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
+13
View File
@@ -0,0 +1,13 @@
# Rollback FPS example
A demo FPS game, based on netfox's `RollbackSynchronizer` and
`RewindableAction`, to demonstrate how to create a server-authoritative
shooter.
Player stats and firing are handled from inside the [rollback tick loop],
taking advantage of the latency compensation provided by rollback.
To edit and/or run, open the Godot project in the repository root, and open the
scene in this directory.
[rollback tick loop]: https://foxssake.github.io/netfox/latest/netfox/guides/network-rollback/
@@ -0,0 +1,97 @@
[gd_scene load_steps=17 format=3 uid="uid://dw3xd5a7xnse7"]
[ext_resource type="Script" path="res://examples/rollback-fps/scripts/player.gd" id="1_bww6v"]
[ext_resource type="Script" path="res://examples/rollback-fps/scripts/player-weapon.gd" id="2_qyetr"]
[ext_resource type="AudioStream" uid="uid://cope814m1d7p6" path="res://examples/multiplayer-fps/sounds/fire.mp3" id="3_kq0j8"]
[ext_resource type="Script" path="res://addons/netfox/rewindable-action.gd" id="6_pks3d"]
[ext_resource type="Script" path="res://examples/rollback-fps/scripts/player-input.gd" id="7_2i3bi"]
[ext_resource type="Script" path="res://addons/netfox/rollback/rollback-synchronizer.gd" id="9_vik3r"]
[ext_resource type="Script" path="res://addons/netfox/tick-interpolator.gd" id="10_4mqhe"]
[ext_resource type="AudioStream" uid="uid://q7ql86p7flj" path="res://examples/multiplayer-fps/sounds/hit.wav" id="15_1n4tg"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_6kydi"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_2gc5u"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh_xnks4"]
material = SubResource("StandardMaterial3D_2gc5u")
[sub_resource type="BoxMesh" id="BoxMesh_qmeb2"]
size = Vector3(0.4, 0.25, 0.5)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_xx8rr"]
albedo_color = Color(0, 0, 0, 1)
[sub_resource type="BoxMesh" id="BoxMesh_0vqha"]
material = SubResource("StandardMaterial3D_xx8rr")
size = Vector3(0.2, 0.2, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_khk4l"]
albedo_color = Color(0, 0, 0, 1)
[sub_resource type="BoxMesh" id="BoxMesh_wqpqp"]
material = SubResource("StandardMaterial3D_khk4l")
size = Vector3(0.2, 0.2, 1)
[node name="player" type="CharacterBody3D"]
script = ExtResource("1_bww6v")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_6kydi")
[node name="Body" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh_xnks4")
[node name="Head" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.705698, 0)
[node name="Camera3D" type="Camera3D" parent="Head"]
[node name="Nose" type="MeshInstance3D" parent="Head"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.25)
mesh = SubResource("BoxMesh_qmeb2")
[node name="PlayerFPSWeapon" type="Node3D" parent="Head"]
transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0)
script = ExtResource("2_qyetr")
[node name="TinyGun" type="MeshInstance3D" parent="Head/PlayerFPSWeapon"]
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.15, 0.09, -0.18, -0.3)
mesh = SubResource("BoxMesh_0vqha")
[node name="Fire Action" type="Node" parent="Head/PlayerFPSWeapon"]
script = ExtResource("6_pks3d")
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="Head/PlayerFPSWeapon"]
transform = Transform3D(-1, 0, 8.74228e-08, 0, 1, 0, -8.74228e-08, 0, -1, 0, -0.705698, 0)
stream = ExtResource("3_kq0j8")
[node name="BigGun" type="MeshInstance3D" parent="Head"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.439686, -0.406913, -0.200588)
mesh = SubResource("BoxMesh_wqpqp")
[node name="DisplayNameLabel3D" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.30427, 0)
pixel_size = 0.002
billboard = 1
fixed_size = true
font_size = 16
[node name="RollbackSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")]
unique_name_in_owner = true
script = ExtResource("9_vik3r")
root = NodePath("..")
state_properties = Array[String]([":transform", ":velocity", ":health", ":deaths", "Head:transform", "Head/PlayerFPSWeapon:last_fire"])
input_properties = Array[String](["Input:movement", "Input:jump", "Input:fire", "Input:look_angle"])
[node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")]
script = ExtResource("10_4mqhe")
root = NodePath("..")
properties = Array[String]([":transform", "Head:transform"])
[node name="Input" type="Node" parent="."]
script = ExtResource("7_2i3bi")
mouse_sensitivity = 0.005
[node name="Hit SFX" type="AudioStreamPlayer3D" parent="."]
stream = ExtResource("15_1n4tg")
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
extends BaseNetInput
@export var mouse_sensitivity: float = 1.0
var override_mouse: bool = false
# Input variables
var mouse_rotation: Vector2 = Vector2.ZERO
var look_angle: Vector2 = Vector2.ZERO
var movement: Vector3 = Vector3.ZERO
var fire: bool = false
var jump: bool = false
func _notification(what):
if what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
override_mouse = false
func _input(event: InputEvent) -> void:
if !is_multiplayer_authority(): return
if event is InputEventMouseMotion:
mouse_rotation.y += event.relative.x * mouse_sensitivity
mouse_rotation.x += event.relative.y * mouse_sensitivity
if event.is_action_pressed("escape"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
override_mouse = true
func _ready():
super()
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _gather():
# Get the input direction and handle the movement/deceleration.
# As good practice, you should replace UI actions with custom gameplay actions.
var mx = Input.get_axis("move_west", "move_east")
var mz = Input.get_axis("move_north", "move_south")
movement = Vector3(mx, 0, mz)
jump = Input.is_action_pressed("move_jump")
fire = Input.is_action_pressed("mouse_weapon_fire")
if override_mouse:
look_angle = Vector2.ZERO
mouse_rotation = Vector2.ZERO
else:
look_angle = Vector2(-mouse_rotation.y, -mouse_rotation.x)
mouse_rotation = Vector2.ZERO
@@ -0,0 +1 @@
uid://efvmt2o8yu0e
@@ -0,0 +1,69 @@
extends Node
@export var player_scene: PackedScene
@export var spawn_points: Array[Marker3D] = []
var avatars: Dictionary = {}
static var _logger := NetfoxLogger.new("RollbackFPS", "PlayerSpawner")
func _ready():
NetworkEvents.on_client_start.connect(_handle_connected)
NetworkEvents.on_server_start.connect(_handle_host)
NetworkEvents.on_peer_join.connect(_handle_new_peer)
NetworkEvents.on_peer_leave.connect(_handle_leave)
NetworkEvents.on_client_stop.connect(_handle_stop)
NetworkEvents.on_server_stop.connect(_handle_stop)
func _handle_connected(id: int):
# Spawn an avatar for us
_spawn(id)
func _handle_host():
# Spawn own avatar on host machine
_spawn(1)
func _handle_new_peer(id: int):
# Spawn an avatar for new player
_spawn(id)
func _handle_leave(id: int):
if not avatars.has(id):
return
var avatar = avatars[id] as Node
avatar.queue_free()
avatars.erase(id)
func _handle_stop():
# Remove all avatars on game end
for avatar in avatars.values():
avatar.queue_free()
avatars.clear()
func _spawn(id: int):
var avatar = player_scene.instantiate() as Node
avatars[id] = avatar
avatar.name += " #%d" % id
add_child(avatar)
avatar.global_position = get_next_spawn_point(id)
# Avatar is always owned by server
avatar.set_multiplayer_authority(1)
_logger.info("Spawned avatar %s at %s" % [avatar.name, multiplayer.get_unique_id()])
# Avatar's input object is owned by player
var input = avatar.find_child("Input")
if input != null:
input.set_multiplayer_authority(id)
_logger.info("Set input(%s) ownership to %s" % [input.name, id])
func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3:
# The same data is used to calculate the index on all peers
# As a result, spawn points are the same, even without sync
var idx := peer_id * 37 + spawn_idx * 19
idx = hash(idx)
idx = idx % spawn_points.size()
return spawn_points[idx].global_position
@@ -0,0 +1 @@
uid://cr4fhpo4ht4mr
@@ -0,0 +1,72 @@
extends Node3D
@export var fire_cooldown: float = 0.25
@export var damage: int = 35
@onready var input := $"../../Input" as ExampleRollbackFPS.PlayerInput
@onready var sound := $AudioStreamPlayer3D as AudioStreamPlayer3D
@onready var fire_action := $"Fire Action" as RewindableAction
@onready var rollback_synchronizer := %RollbackSynchronizer as RollbackSynchronizer
var last_fire: int = -1
func _ready():
fire_action.mutate(self) # Mutate self, so firing code can run
fire_action.mutate($"../../") # Mutate player
NetworkTime.after_tick_loop.connect(_after_loop)
func _rollback_tick(_dt, tick: int, _if):
if rollback_synchronizer.is_predicting():
return
fire_action.set_active(input.fire and _can_fire())
match fire_action.get_status():
RewindableAction.CONFIRMING, RewindableAction.ACTIVE:
# Fire if action has just activated or is active
_fire()
RewindableAction.CANCELLING:
# Whoops, turns out we couldn't have fired, undo
_unfire()
func _after_loop():
if fire_action.has_confirmed():
sound.play()
func _can_fire() -> bool:
return NetworkTime.seconds_between(last_fire, NetworkRollback.tick) >= fire_cooldown
func _fire():
last_fire = NetworkRollback.tick
# See what we've hit
var hit := _raycast()
if hit.is_empty():
# No hit, nothing to do
return
_on_hit(hit)
func _unfire():
fire_action.erase_context()
func _raycast() -> Dictionary:
# Detect hit
var space := get_world_3d().direct_space_state
var origin_xform := global_transform
var query := PhysicsRayQueryParameters3D.create(
origin_xform.origin,
origin_xform.origin + origin_xform.basis.z * 1024.
)
return space.intersect_ray(query)
func _on_hit(result: Dictionary):
var is_new_hit := false
if not fire_action.has_context():
fire_action.set_context(true)
is_new_hit = true
if result.collider.has_method("damage"):
result.collider.damage(damage, is_new_hit)
NetworkRollback.mutate(result.collider)
@@ -0,0 +1 @@
uid://da0u0nkxkqnpt
+117
View File
@@ -0,0 +1,117 @@
extends CharacterBody3D
@export var speed = 5.0
@export var jump_strength = 5.0
@onready var display_name := $DisplayNameLabel3D as Label3D
@onready var input := $Input as ExampleRollbackFPS.PlayerInput
@onready var tick_interpolator := $TickInterpolator as TickInterpolator
@onready var head := $Head as Node3D
@onready var camera := $Head/Camera3D as Camera3D
@onready var hit_sfx := $"Hit SFX" as AudioStreamPlayer3D
#+#
static var _logger := NetfoxLogger.new("game", "Player")
var gravity = ProjectSettings.get_setting(&"physics/3d/default_gravity")
var health: int = 100
var death_tick: int = -1
var respawn_position: Vector3
var did_respawn := false
var deaths := 0
# Track deaths and *acknowledged* deaths
# Acknowledge the number of deaths on tick loop start
# If the value changes by the end of the loop, that means the player has
# respawned, and needs to `teleport()`
var _ackd_deaths := 0
var _was_hit := false
func _ready():
display_name.text = name
NetworkTime.before_tick_loop.connect(_before_tick_loop)
NetworkTime.after_tick_loop.connect(_after_tick_loop)
# Wait for deps to setup
await get_tree().process_frame
if input.is_multiplayer_authority():
camera.current = true
ExampleRollbackFPS.HUD.set_player(self)
func _before_tick_loop():
_ackd_deaths = deaths
func _after_tick_loop():
if _ackd_deaths != deaths:
tick_interpolator.teleport()
_ackd_deaths = deaths
if _was_hit:
hit_sfx.play()
_was_hit = false
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
# Handle respawn
if tick == death_tick:
global_position = respawn_position
did_respawn = true
else:
did_respawn = false
# Gravity
_force_update_is_on_floor()
if is_on_floor():
if input.jump:
velocity.y = jump_strength
else:
velocity.y -= gravity * delta
# Handle look left and right
rotate_object_local(Vector3(0, 1, 0), input.look_angle.x)
# Handle look up and down
head.rotate_object_local(Vector3(1, 0, 0), input.look_angle.y)
head.rotation.x = clamp(head.rotation.x, -1.57, 1.57)
head.rotation.z = 0
head.rotation.y = 0
# Apply movement
var input_dir = input.movement
var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.z)).normalized()
if direction:
velocity.x = direction.x * speed
velocity.z = direction.z * speed
else:
velocity.x = move_toward(velocity.x, 0, speed)
velocity.z = move_toward(velocity.z, 0, speed)
# move_and_slide assumes physics delta
# multiplying velocity by NetworkTime.physics_factor compensates for it
velocity *= NetworkTime.physics_factor
move_and_slide()
velocity /= NetworkTime.physics_factor
# Handle death
if health <= 0:
deaths += 1
global_position = get_parent().get_next_spawn_point(get_player_id(), deaths)
health = 100
func _force_update_is_on_floor():
var old_velocity = velocity
velocity = Vector3.ZERO
move_and_slide()
velocity = old_velocity
func damage(amount: int, is_new_hit: bool = false):
# Queue hit sound
if is_new_hit:
_was_hit = true
health -= amount
_logger.info("%s HP now at %s", [name, health])
func get_player_id() -> int:
return input.get_multiplayer_authority()
@@ -0,0 +1 @@
uid://16cr653lwxno
@@ -0,0 +1,7 @@
extends Object
class_name ExampleRollbackFPS
const Player := preload("res://examples/rollback-fps/scripts/player.gd")
const PlayerInput := preload("res://examples/rollback-fps/scripts/player-input.gd")
const PlayerWeapon := preload("res://examples/rollback-fps/scripts/player-weapon.gd")
const HUD := preload("res://examples/rollback-fps/ui/fps-hud.gd")
@@ -0,0 +1 @@
uid://b3ixo1b52q06q
+26
View File
@@ -0,0 +1,26 @@
extends Control
@onready var health_bar := $ProgressBar as ProgressBar
var player: ExampleRollbackFPS.Player
static var _inst
static func set_player(p_player: ExampleRollbackFPS.Player) -> void:
if not _inst: return
_inst.player = p_player
_inst.visible = true
_inst.set_process(true)
func _ready():
visible = false
set_process(false)
_inst = self
func _process(_dt) -> void:
if not is_instance_valid(player):
set_process(false)
return
health_bar.value = player.health
+1
View File
@@ -0,0 +1 @@
uid://cycmne5n6ov65
+53
View File
@@ -0,0 +1,53 @@
[gd_scene load_steps=4 format=3 uid="uid://cdue87s0gmc4d"]
[ext_resource type="Script" path="res://examples/rollback-fps/ui/fps-hud.gd" id="1_gc5cb"]
[sub_resource type="Gradient" id="Gradient_3rjdk"]
offsets = PackedFloat32Array(0.962667, 1)
colors = PackedColorArray(1, 1, 1, 1, 1, 1, 1, 0)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_dsv61"]
gradient = SubResource("Gradient_3rjdk")
width = 8
height = 8
fill = 1
fill_from = Vector2(0.5, 0.5)
fill_to = Vector2(1, 0.5)
metadata/_snap_enabled = true
[node name="fps-hud" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_gc5cb")
[node name="ProgressBar" type="ProgressBar" parent="."]
custom_minimum_size = Vector2(128, 0)
layout_mode = 1
anchors_preset = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_top = -27.0
offset_right = 128.0
grow_vertical = 0
[node name="TextureRect" type="TextureRect" parent="."]
custom_minimum_size = Vector2(8, 8)
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -135.0
offset_top = -4.0
offset_right = 135.0
offset_bottom = 4.0
grow_horizontal = 2
grow_vertical = 2
texture = SubResource("GradientTexture2D_dsv61")
expand_mode = 3
stretch_mode = 5