extends Node class_name AntiCheat # ═════════════════════════════════════════════════════════ # Server-authoritative anti-cheat validation coordinator # # Per-player state tracking and input validation pipeline. # Called from the server's _physics_process per player per tick. # # Validation pipeline order: # 1. Input sequence (timestamp monotonic, seq# dedup) # 2. Movement (speed limit, teleport, multi-jump) # 3. Aim (max snap rotation per tick) # 4. Fire rate (interval between shots, ammo consistency) # 5. Command rate (commands/second limit) # 6. Enforcement (log / correct / warn / kick) # # Designed to be loaded once on the server and called per tick # from the game's player state update loop. # ═════════════════════════════════════════════════════════ # ─── Enforcement Levels ────────────────────────────────── enum AcLevel { OFF = 0, # Disabled — no validation performed LOG = 1, # Log violations to server console only CORRECT = 2, # Correct invalid input + warn player (default) KICK = 3, # Kick player on threshold breach } # ─── Violation Categories ──────────────────────────────── enum ViolationCategory { INPUT_SEQUENCE, # Bad sequence / replay / timestamp MOVEMENT_SPEED, # Exceeded speed limit MOVEMENT_TELEPORT, # Instant position change MOVEMENT_MULTI_JUMP, # Jump without ground contact AIM_SNAP, # View-angle delta exceeds limit FIRE_RATE_INTERVAL, # Shots too close together FIRE_RATE_AMMO, # Ammo delta inconsistent with shot COMMAND_RATE, # > max_commands/sec from client } # ─── Cvars (exported for inspector; override via config system) ── ## Enforcement level (0=off, 1=log, 2=correct, 3=kick) @export_range(0, 3, 1) var sv_ac_level: int = 2 ## Speed multiplier before flagging (1.2 = 20% over max) @export_range(1.0, 3.0, 0.05) var sv_ac_speed_tolerance: float = 1.2 ## Max view-angle delta in degrees per tick @export_range(10.0, 360.0, 1.0) var sv_ac_max_aim_snap: float = 180.0 ## Fire interval tolerance in ms above weapon's fire rate @export_range(0, 100, 1) var sv_ac_fire_tolerance_ms: int = 10 ## Max commands from one client per second @export_range(64, 254, 1) var sv_ac_command_rate_max: int = 128 ## Max distance units per tick before flagged as teleport @export_range(10.0, 500.0, 1.0) var sv_ac_teleport_threshold: float = 100.0 ## Violations before kick (only at AcLevel.KICK) @export_range(1, 50, 1) var sv_ac_kick_threshold: int = 5 ## Enable/disable multi-jump check @export var sv_ac_check_multi_jump: bool = true ## Enable/disable ammo consistency check @export var sv_ac_check_ammo: bool = true # ─── Signals ───────────────────────────────────────────── ## Emitted when a violation triggers a kick decision. signal player_kicked(peer_id: int, reason: String, violation_count: int) ## Emitted on every validated violation (at all enforcement levels). signal violation_recorded(peer_id: int, category: int, details: String) ## Emitted when the anti-cheat corrects an input field. signal input_corrected(peer_id: int, field: String, original_value, clamped_value) # ─── Per-Player Tracked State ──────────────────────────── class PlayerState: var peer_id: int var last_seq: int = -1 var last_timestamp: float = 0.0 var last_position: Vector3 = Vector3.ZERO var last_view_angles: Vector2 = Vector2.ZERO var last_fire_time: float = 0.0 var last_ammo: int = -1 var on_ground: bool = true # server-authoritative (set via set_ground_contact) var jump_count: int = 0 # consecutive airborne jumps var last_fire_pressed: bool = false var _aim_initialized: bool = false # has aim been recorded at least once? var command_times: Array[float] = [] var violation_count: int = 0 var violations_log: Array[Dictionary] = [] var connected_at: float = 0.0 func _init(id: int, time: float): peer_id = id connected_at = time func reset(time: float) -> void: last_seq = -1 last_timestamp = 0.0 last_position = Vector3.ZERO last_view_angles = Vector2.ZERO last_fire_time = 0.0 last_ammo = -1 on_ground = true jump_count = 0 last_fire_pressed = false _aim_initialized = false command_times.clear() violation_count = 0 violations_log.clear() connected_at = time # ─── Internal State ────────────────────────────────────── var _player_states: Dictionary = {} # peer_id → PlayerState # ─── Public API ────────────────────────────────────────── func register_player(peer_id: int) -> void: """Register a new player for anti-cheat tracking. Call when a player fully connects to the server. The player's state is initialized fresh. """ var state = PlayerState.new(peer_id, Time.get_ticks_usec() / 1_000_000.0) _player_states[peer_id] = state func unregister_player(peer_id: int) -> void: """Remove a player from anti-cheat tracking. Call on player disconnect to clean up state. """ _player_states.erase(peer_id) func is_tracking(peer_id: int) -> bool: """Returns true if peer_id is being tracked.""" return _player_states.has(peer_id) func validate_input(peer_id: int, input_packet: Dictionary, delta: float) -> Dictionary: """Run full validation pipeline on a player's input packet. Parameters: peer_id: Network peer ID of the player input_packet: Dict with keys: seq (int) — monotonic sequence number time (float) — client timestamp (seconds) movement (Vector3) — movement direction view_angles (Vector2) — camera pitch/yaw buttons (int) — bitmask: 1=jump, 2=fire, 4=crouch, 8=use ammo (int) — current weapon ammo (-1 if unknown/melee) position (Vector3) — player world position max_speed (float, optional) — current max player speed weapon_fire_rate_ms (float, optional) — current fire interval burst_count (int, optional) — ammo per burst (default: 1) delta: Physics tick delta (e.g. 1/128 ≈ 0.0078) Integration note: After calling validate_input() and applying corrected input to physics, call set_ground_contact(peer_id, on_ground) to furnish server-authoritative ground-state for the next tick's multi-jump validation. Returns: Dict with: corrected (Dictionary) — cleaned input packet (may have clamped fields) violations (Array[Dictionary]) — violations detected this tick action_taken (String) — "" / "warned" / "kicked" """ var result = { "corrected": input_packet.duplicate(), "violations": [], "action_taken": "", } if sv_ac_level == AcLevel.OFF or not _player_states.has(peer_id): return result var state: PlayerState = _player_states[peer_id] # ── Pipeline ── # 1. Input sequence validation (always runs) _result_seq(_validate_sequence(input_packet, state, result["corrected"]), peer_id, state, result) # 2. Movement validation _result_mv(_validate_movement(input_packet, state, delta, result["corrected"]), peer_id, state, result) # 3. Aim validation _result_aim(_validate_aim(input_packet, state, result["corrected"]), peer_id, state, result) # 4. Fire rate validation _result_fire(_validate_fire_rate(input_packet, state, result["corrected"]), peer_id, state, result) # 5. Command rate validation _result_cmd(_validate_command_rate(input_packet, state), peer_id, state, result) # 6. Ammo validation if sv_ac_check_ammo: _result_ammo(_validate_ammo(input_packet, state, result["corrected"]), peer_id, state, result) # ── Enforcement (post-pipeline) ── if state.violation_count > 0 and result["violations"].size() > 0: # Set warned flag at any level above OFF if sv_ac_level >= AcLevel.CORRECT: result["action_taken"] = "warned" # Kick decision — at KICK level when threshold breached if sv_ac_level >= AcLevel.KICK and state.violation_count >= sv_ac_kick_threshold: var reason: String = "Anti-cheat violation threshold reached (%d/%d)" % [ state.violation_count, sv_ac_kick_threshold ] player_kicked.emit(peer_id, reason, state.violation_count) result["action_taken"] = "kicked" # ── Post-validation bookkeeping ── if input_packet.has("position") and typeof(input_packet.position) == TYPE_VECTOR3: state.last_position = result["corrected"].get("position", input_packet.position) if input_packet.has("time"): state.last_timestamp = input_packet.time if input_packet.has("view_angles"): state.last_view_angles = result["corrected"].get("view_angles", input_packet.view_angles) return result func get_player_violations(peer_id: int) -> Array: """Return a copy of the violation log for a player.""" if not _player_states.has(peer_id): return [] return _player_states[peer_id].violations_log.duplicate() func get_player_violation_count(peer_id: int) -> int: """Return the total violation count for a player.""" if not _player_states.has(peer_id): return 0 return _player_states[peer_id].violation_count func reset_player_violations(peer_id: int) -> void: """Reset violation counter for a player (e.g. after admin pardon).""" if _player_states.has(peer_id): var s: PlayerState = _player_states[peer_id] s.violation_count = 0 s.violations_log.clear() func clear_all() -> void: """Remove all tracked players.""" _player_states.clear() func set_ground_contact(peer_id: int, on_ground: bool) -> void: """Set the server-authoritative ground-contact state for a player. Must be called by the game server AFTER physics simulation each tick, so the anti-cheat uses authoritative ground state for multi-jump detection on the next tick. """ if _player_states.has(peer_id): _player_states[peer_id].on_ground = on_ground # ─── Individual Validators ─────────────────────────────── func _validate_sequence(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary: """Check sequence monotonicity and timestamp monotonicity. Returns violation Dictionaries or null. """ var violations: Array[Dictionary] = [] var seq: int = packet.get("seq", -1) var time: float = packet.get("time", 0.0) # First packet always accepted if state.last_seq == -1: state.last_seq = seq state.last_timestamp = time return {"violations": violations, "invalid": false} var invalid := false # Sequence must increase monotonically if seq <= state.last_seq: violations.append(_make_violation( ViolationCategory.INPUT_SEQUENCE, "Non-monotonic sequence: %d → %d" % [state.last_seq, seq], )) invalid = true # Timestamp must increase monotonically if time < state.last_timestamp: violations.append(_make_violation( ViolationCategory.INPUT_SEQUENCE, "Timestamp regression: %.4f → %.4f" % [state.last_timestamp, time], )) invalid = true # Update state on valid (or lightly correct) if not invalid: state.last_seq = seq state.last_timestamp = time else: # Correct: bump seq forward so we don't accumulate stale state if seq <= state.last_seq: corrected["seq"] = state.last_seq + 1 return {"violations": violations, "invalid": invalid} func _validate_movement(packet: Dictionary, state: PlayerState, delta: float, corrected: Dictionary) -> Dictionary: """Check speed limit, teleport, and multi-jump. The player's max speed is defined by the movement controller. We use a configurable tolerance multiplier. """ var violations: Array[Dictionary] = [] if not packet.has("position") or typeof(packet.position) != TYPE_VECTOR3: return {"violations": violations, "corrected": false} var pos: Vector3 = packet.position var prev_pos: Vector3 = state.last_position var displacement: Vector3 = pos - prev_pos var distance: float = displacement.length() # ── Teleport check ── if distance > sv_ac_teleport_threshold and state.last_seq != -1: violations.append(_make_violation( ViolationCategory.MOVEMENT_TELEPORT, "Teleport: moved %.1f units in one tick (threshold %.1f)" % [distance, sv_ac_teleport_threshold], {"distance": distance, "threshold": sv_ac_teleport_threshold}, )) # Correct: reset position to previous corrected["position"] = prev_pos return {"violations": violations, "corrected": true} # ── Speed check ── # Max speed varies by weapon/game state; use a configurable tolerance # over the assumed base speed. The actual game should pass its max_speed # to validate_input(...) or we use a cvars-driven estimate. var max_speed_units_per_sec: float = _resolve_max_speed(packet) var max_speed_this_tick: float = max_speed_units_per_sec * sv_ac_speed_tolerance * delta var speed: float = distance / max(delta, 0.001) if distance > max_speed_this_tick and state.last_seq != -1: violations.append(_make_violation( ViolationCategory.MOVEMENT_SPEED, "Speed: %.1f u/s (max %.1f u/s × %.1f tolerance)" % [speed, max_speed_units_per_sec, sv_ac_speed_tolerance], {"speed": speed, "max_allowed": max_speed_this_tick / delta}, )) # Correct: clamp displacement to max permitted if distance > 0.001: var clamped_displacement: Vector3 = displacement.normalized() * min(distance, max_speed_this_tick) corrected["position"] = prev_pos + clamped_displacement return {"violations": violations, "corrected": true} # ── Multi-jump check ── # jump_count only resets on ground contact, NOT on button release, # to prevent tap-jump bypass (pressing jump repeatedly while airborne). if sv_ac_check_multi_jump: var jump_pressed: bool = (packet.get("buttons", 0) & 1) != 0 var was_on_ground: bool = state.on_ground if was_on_ground: state.jump_count = 0 elif jump_pressed: state.jump_count += 1 var max_jumps: int = 1 # only one jump allowed without ground contact if not was_on_ground and state.jump_count > max_jumps: violations.append(_make_violation( ViolationCategory.MOVEMENT_MULTI_JUMP, "Multi-jump: %d jumps without ground contact" % state.jump_count, {"jump_count": state.jump_count, "max_jumps": max_jumps}, )) # Correct: suppress the jump flag corrected["buttons"] = packet.get("buttons", 0) & ~1 return {"violations": violations, "corrected": true} return {"violations": violations, "corrected": false} func _validate_aim(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary: """Check view-angle delta per tick doesn't exceed max snap. view_angles is Vector2(pitch, yaw) in degrees. """ var violations: Array[Dictionary] = [] if not packet.has("view_angles"): return {"violations": violations, "corrected": false} var angles: Vector2 = packet.view_angles # First tick — just record (no aim-initialized state yet) if not state._aim_initialized: state._aim_initialized = true return {"violations": violations, "corrected": false} # Compute shortest angular distance per axis, handling yaw wraparound var delta_angles := Vector2() for axis in [0, 1]: var raw_delta: float = angles[axis] - state.last_view_angles[axis] var wrapped_delta: float = fmod(raw_delta, 360.0) if wrapped_delta > 180.0: wrapped_delta -= 360.0 elif wrapped_delta < -180.0: wrapped_delta += 360.0 delta_angles[axis] = abs(wrapped_delta) var max_delta: float = max(delta_angles.x, delta_angles.y) if max_delta > sv_ac_max_aim_snap and state.last_seq != -1: violations.append(_make_violation( ViolationCategory.AIM_SNAP, "Aim snap: %.1f° in one tick (max %.1f°)" % [max_delta, sv_ac_max_aim_snap], {"delta": max_delta, "max_allowed": sv_ac_max_aim_snap}, )) # Correct: clamp delta per axis, handling yaw wraparound var clamped_angles := state.last_view_angles for axis in [0, 1]: var diff: float = angles[axis] - state.last_view_angles[axis] # Shortest-path diff, handling wraparound var wrapped_diff: float = fmod(diff, 360.0) if wrapped_diff > 180.0: wrapped_diff -= 360.0 elif wrapped_diff < -180.0: wrapped_diff += 360.0 # If the shortest path is within snap limit, allow it; otherwise clamp raw diff if abs(wrapped_diff) > sv_ac_max_aim_snap: var clamped_diff: float = clamp(diff, -sv_ac_max_aim_snap, sv_ac_max_aim_snap) clamped_angles[axis] = state.last_view_angles[axis] + clamped_diff corrected["view_angles"] = clamped_angles return {"violations": violations, "corrected": true} return {"violations": violations, "corrected": false} func _validate_fire_rate(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary: """Check minimum interval between shots. Weapon fire rate is represented as min_interval_ms. If the game provides it in the packet, great; otherwise use a reasonable minimum (based on fastest weapon in the game: ~100ms = 600rpm). """ var violations: Array[Dictionary] = [] var buttons: int = packet.get("buttons", 0) var fire_pressed: bool = (buttons & 2) != 0 if not fire_pressed: state.last_fire_pressed = false return {"violations": violations, "corrected": false} var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0) var elapsed_ms: float = (now - state.last_fire_time) * 1000.0 # If weapon provides its fire interval, use it; else use a reasonable min var weapon_min_interval_ms: float = packet.get("weapon_fire_rate_ms", 80.0) var allowed_interval_ms: float = weapon_min_interval_ms - sv_ac_fire_tolerance_ms # First fire always accepted if state.last_fire_time <= 0.0: state.last_fire_time = now state.last_fire_pressed = true return {"violations": violations, "corrected": false} if elapsed_ms < allowed_interval_ms: violations.append(_make_violation( ViolationCategory.FIRE_RATE_INTERVAL, "Fire rate: %.1fms interval (min allowed %.1fms)" % [elapsed_ms, allowed_interval_ms], {"elapsed_ms": elapsed_ms, "min_allowed_ms": allowed_interval_ms}, )) # Correct: suppress fire button corrected["buttons"] = buttons & ~2 state.last_fire_pressed = false return {"violations": violations, "corrected": true} state.last_fire_time = now state.last_fire_pressed = true return {"violations": violations, "corrected": false} func _validate_command_rate(packet: Dictionary, state: PlayerState) -> Dictionary: """Limit commands per second from a client. Commands include any client→server packet. We track a sliding window of recent command timestamps. """ var violations: Array[Dictionary] = [] var now: float = packet.get("time", Time.get_ticks_usec() / 1_000_000.0) var window_start: float = now - 1.0 # 1 second window # Prune old entries var i: int = 0 while i < state.command_times.size(): if state.command_times[i] < window_start: state.command_times.remove_at(i) else: i += 1 state.command_times.append(now) # Allow a small burst at the start of a connection var is_new_connection: bool = (now - state.connected_at) < 3.0 var burst_allowed: int = 4 var limit: int = sv_ac_command_rate_max + (burst_allowed if is_new_connection else 0) if state.command_times.size() > limit: # Log one violation per excess over the limit var excess: int = state.command_times.size() - limit if excess > 0: violations.append(_make_violation( ViolationCategory.COMMAND_RATE, "Command rate: %d/s (max %d/s)" % [state.command_times.size(), sv_ac_command_rate_max], {"actual_rate": state.command_times.size(), "max_rate": sv_ac_command_rate_max}, )) # Keep window bounded — single cleanup pass while state.command_times.size() > limit + 8: state.command_times.remove_at(0) return {"violations": violations} func _validate_ammo(packet: Dictionary, state: PlayerState, corrected: Dictionary) -> Dictionary: """Check ammo delta consistency. If the player fired last tick, ammo should decrease by at most 1 (or by burst count if provided). """ var violations: Array[Dictionary] = [] if not packet.has("ammo"): return {"violations": violations, "corrected": false} var ammo: int = packet.ammo if state.last_ammo == -1: state.last_ammo = ammo return {"violations": violations, "corrected": false} var buttons: int = packet.get("buttons", 0) var fire_this: bool = (buttons & 2) != 0 var delta_ammo: int = ammo - state.last_ammo # Expected: negative if fired, 0 if not fired, positive if picking up ammo if state.last_fire_time > 0 and fire_this: # Should have decreased by 1 (or burst count) var expected_delta: int = -packet.get("burst_count", 1) if delta_ammo > 0: violations.append(_make_violation( ViolationCategory.FIRE_RATE_AMMO, "Ammo increased while firing: %d → %d" % [state.last_ammo, ammo], {"last_ammo": state.last_ammo, "new_ammo": ammo}, )) # Correct: keep old ammo value corrected["ammo"] = state.last_ammo return {"violations": violations, "corrected": true} elif delta_ammo > expected_delta: # Less ammo used than expected (e.g., 0 instead of -1) violations.append(_make_violation( ViolationCategory.FIRE_RATE_AMMO, "Ammo mismatch while firing: delta %d (expected %d)" % [delta_ammo, expected_delta], {"delta": delta_ammo, "expected": expected_delta}, )) corrected["ammo"] = max(0, state.last_ammo + expected_delta) return {"violations": violations, "corrected": true} state.last_ammo = ammo return {"violations": violations, "corrected": false} # ─── Enforcement Helpers ──────────────────────────────── func _make_violation(category: int, details: String, extra: Dictionary = {}) -> Dictionary: var v := { "category": category, "category_name": ViolationCategory.keys()[category], "details": details, "time": Time.get_ticks_usec() / 1_000_000.0, "extra": extra, } return v func _record_violation(state: PlayerState, v: Dictionary) -> void: state.violation_count += 1 state.violations_log.append(v) # Keep log bounded if state.violations_log.size() > 100: state.violations_log.pop_front() func _resolve_max_speed(packet: Dictionary) -> float: """Get max player speed from packet or default. The game should set 'max_speed' in the input packet. If not provided, assume a standard FPS walking speed. """ if packet.has("max_speed"): return packet.max_speed return 320.0 # Default: CS-style max speed # ─── Pipeline Result Handlers ────────────────────────── func _result_seq(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void: for v in r.get("violations", []): _record_violation(state, v) result["violations"].append(v) violation_recorded.emit(peer_id, v.category, v.details) func _result_mv(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void: for v in r.get("violations", []): _record_violation(state, v) result["violations"].append(v) violation_recorded.emit(peer_id, v.category, v.details) if r.get("corrected", false): input_corrected.emit(peer_id, "position", null, null) func _result_aim(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void: for v in r.get("violations", []): _record_violation(state, v) result["violations"].append(v) violation_recorded.emit(peer_id, v.category, v.details) if r.get("corrected", false): input_corrected.emit(peer_id, "view_angles", null, null) func _result_fire(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void: for v in r.get("violations", []): _record_violation(state, v) result["violations"].append(v) violation_recorded.emit(peer_id, v.category, v.details) if r.get("corrected", false): input_corrected.emit(peer_id, "fire", null, null) func _result_cmd(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void: for v in r.get("violations", []): _record_violation(state, v) result["violations"].append(v) violation_recorded.emit(peer_id, v.category, v.details) func _result_ammo(r: Dictionary, peer_id: int, state: PlayerState, result: Dictionary) -> void: for v in r.get("violations", []): _record_violation(state, v) result["violations"].append(v) violation_recorded.emit(peer_id, v.category, v.details) if r.get("corrected", false): input_corrected.emit(peer_id, "ammo", null, null)