## LagCompensation — Server-side lag compensation for hit-scan weapons. ## ## Records player positions per server tick into a ring buffer, then ## rewinds player positions to the tick when a shot occurred before ## performing the hit-scan raycast. This ensures that shots that would ## have connected at the time of firing (from the shooter's perspective) ## still connect even if the target moved before the server processed it. ## ## Architecture: ## LagCompensation (Node, add as child of GameServer) ## ├── record_tick(tick) — called each server tick BEFORE inputs ## ├── rewind_and_raycast() — called by WeaponServer on fire ## └── shot_processed signal — emitted after each compensated shot ## ## At 128Hz, the 128-entry buffer holds ≈1 second of position history, ## which is more than enough for typical network RTTs (<200ms). ## class_name LagCompensation extends Node # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- ## Number of ticks to keep in the position history ring buffer. ## At 128Hz: 128 entries ≈ 1 second of history. const HISTORY_SIZE: int = 128 # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted after a shot has been processed through lag compensation. ## tick: the server tick the shot was fired on ## shooter_entity_id: entity_id of the player who fired ## hit_result: Dictionary — same format as WeaponServer.fire() hit results ## {hit: bool, position: Vector3, target_id: int, damage: float, weapon_id: String} signal shot_processed(tick: int, shooter_entity_id: int, hit_result: Dictionary) # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## Ring buffer of position snapshots per tick. ## Indexed by tick % HISTORY_SIZE. ## Each entry is a Dictionary {entity_id (int): position (Vector3)}. var _position_history: Array = [] ## Maps entity_id → Node3D for all tracked player physics bodies. var _player_nodes: Dictionary = {} # entity_id (int) → Node3D # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _init() -> void: _position_history.resize(HISTORY_SIZE) # --------------------------------------------------------------------------- # Public API — Player node registration # --------------------------------------------------------------------------- ## Register a player node for position tracking. ## entity_id: the simulation entity ID assigned by GameServer. ## node: the Node3D (CharacterBody3D) whose position to track. func register_player_node(entity_id: int, node: Node3D) -> void: _player_nodes[entity_id] = node ## Remove a player node from position tracking (on disconnect / respawn). func unregister_player_node(entity_id: int) -> void: _player_nodes.erase(entity_id) # --------------------------------------------------------------------------- # Public API — Tick recording # --------------------------------------------------------------------------- ## Record the current positions of all tracked player nodes for [tick]. ## Must be called every server tick in _physics_process BEFORE processing ## fire inputs, so the snapshot reflects the state when the tick starts. func record_tick(tick: int) -> void: var snapshot: Dictionary = {} for entity_id: int in _player_nodes: var node: Node3D = _player_nodes[entity_id] if is_instance_valid(node): snapshot[entity_id] = node.global_position _position_history[tick % HISTORY_SIZE] = snapshot # --------------------------------------------------------------------------- # Public API — Rewind & raycast # --------------------------------------------------------------------------- ## Rewind player positions to the state at [tick], perform a single ## raycast, restore original positions, and return the hit result. ## ## Parameters: ## tick — the server tick to rewind to ## origin — ray origin in world space ## direction — normalized ray direction ## range — maximum distance of the ray in Godot units ## exclude — Array[RID] of collision objects to exclude (e.g. shooter) ## ## Returns: ## Dictionary from PhysicsDirectSpaceState3D.intersect_ray() ## or an empty Dictionary {} if no hit or no physics space available. ## ## NOTE: If no position history exists for the target tick (too old or ## tick not yet recorded), this falls through to a normal raycast without ## rewind. This keeps weapons functional even during brief history gaps. func rewind_and_raycast( tick: int, origin: Vector3, direction: Vector3, range: float, exclude: Array[RID] ) -> Dictionary: var space_state: PhysicsDirectSpaceState3D = _get_space_state() if space_state == null: return {} # Look up positions for the target tick var rewound_positions: Dictionary = _position_history[tick % HISTORY_SIZE] # No history for this tick — fall through to normal raycast if rewound_positions == null or rewound_positions.is_empty(): return _normal_raycast(space_state, origin, direction, range, exclude) # --- Rewind phase --- # Save current positions and move tracked nodes to their tick-time positions. # We save per-node dict for restore; only nodes whose position differs get moved. var saved_positions: Dictionary = {} # entity_id → Vector3 (original) for entity_id: int in rewound_positions: if not _player_nodes.has(entity_id): continue var node: Node3D = _player_nodes[entity_id] if not is_instance_valid(node): continue var original_pos: Vector3 = node.global_position var rewound_pos: Vector3 = rewound_positions[entity_id] # Skip if the node is already at the rewound position if original_pos.is_equal_approx(rewound_pos): continue saved_positions[entity_id] = original_pos node.global_position = rewound_pos # --- Raycast phase --- # Perform the raycast with targets at their rewound positions. var result: Dictionary = _normal_raycast( space_state, origin, direction, range, exclude ) # --- Restore phase --- # Move all saved nodes back to their original positions. for entity_id: int in saved_positions: if not _player_nodes.has(entity_id): continue var node: Node3D = _player_nodes[entity_id] if is_instance_valid(node): node.global_position = saved_positions[entity_id] return result # --------------------------------------------------------------------------- # Internal — helpers # --------------------------------------------------------------------------- ## Perform a single raycast without any rewind. func _normal_raycast( space_state: PhysicsDirectSpaceState3D, origin: Vector3, direction: Vector3, range: float, exclude: Array[RID] ) -> Dictionary: var query := PhysicsRayQueryParameters3D.create( origin, origin + direction * range ) query.exclude = exclude query.collide_with_bodies = true query.collide_with_areas = false return space_state.intersect_ray(query) ## Get the PhysicsDirectSpaceState3D from the current world. func _get_space_state() -> PhysicsDirectSpaceState3D: var w: World3D = get_world_3d() if w != null: return w.direct_space_state return null