## AntiCheat — Server-side anti-cheat controller. ## ## Provides basic integrity checks against obvious exploits: ## - Rate limiting (input flood / RPC spam above 130 Hz) ## - Speed hacks (position deltas exceeding max movement × 1.5) ## - Aimbot snap (look-direction changes >45° within one tick) ## ## Suspicion accumulates per player (0.0 – 1.0). Escalating actions: ## 0.3 → warn (server log) ## 0.6 → kick (disconnect) ## 0.8 → ban (temp ban, 1 hour) ## ## Design philosophy: ## - All checks use generous tolerances (1.5× – 2× expected max) to account ## for network jitter and edge cases. ## - When in doubt, do NOT flag. False-positive rate must be near zero. ## - This is Phase-4 basic hardening, NOT a competitive anti-cheat. ## ## Usage (inside GameServer._ready()): ## var ac = AntiCheat.new() ## add_child(ac) ## ac.set_max_speed(ServerConfig.movement_sprint_speed) ## # Wire a ban handler: ## ac.ban_handler = func(p_id, reason): ## multiplayer.disconnect_peer(p_id) ## ## Receive reports from gameplay systems: ## ac.report_input_rate(player_id, 150.0) ## ac.report_movement(player_id, Vector3(12, 0, 0), 1) ## ac.report_look_snap(player_id, from_dir, to_dir) class_name AntiCheat extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when a player's suspicion level changes. signal player_suspicion_changed(player_id: int, level: float) ## Emitted when a player is kicked for anti-cheat violations. ## The caller (GameServer) should disconnect the peer. signal player_kicked(player_id: int, reason: String) ## Emitted when a player is temp-banned for anti-cheat violations. ## The caller should enforce the ban (disconnect + track expiry). signal player_banned(player_id: int, reason: String) # --------------------------------------------------------------------------- # Constants — thresholds # --------------------------------------------------------------------------- ## Maximum input RPC rate (Hz). Margin above the 128 Hz sim tick. const DEFAULT_RATE_LIMIT_HZ: float = 130.0 ## Tolerance multiplier applied to expected max movement speed. const SPEED_TOLERANCE: float = 1.5 ## Maximum allowed look-angle change per tick, in degrees. const AIMBOT_SNAP_DEG: float = 45.0 ## Default maximum player speed (sprint speed from default config). const DEFAULT_MAX_SPEED: float = 7.0 # --------------------------------------------------------------------------- # Constants — suspicion values # --------------------------------------------------------------------------- ## Suspicion added per rate-limit violation. const SUS_INPUT_RATE: float = 0.08 ## Suspicion added per speed-hack violation. const SUS_SPEED_HACK: float = 0.12 ## Suspicion added per aimbot snap. const SUS_AIMBOT: float = 0.10 # --------------------------------------------------------------------------- # Constants — thresholds & timing # --------------------------------------------------------------------------- const WARN_THRESHOLD: float = 0.3 const KICK_THRESHOLD: float = 0.6 const BAN_THRESHOLD: float = 0.8 ## Suspicion decay amount per DECAY_INTERVAL seconds (rewards clean play). const DECAY_AMOUNT: float = 0.02 ## How often (seconds) suspicion decays for clean players. const DECAY_INTERVAL: float = 2.0 ## Temporary ban duration in seconds (1 hour). const BAN_DURATION: float = 3600.0 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- ## Max player speed in units/sec. Set via set_max_speed() to match server ## config. Defaults to DEFAULT_MAX_SPEED. var _max_speed: float = DEFAULT_MAX_SPEED ## Current suspicion per player, 0.0 – 1.0. var _suspicion: Dictionary = {} # player_id (int) → level (float) ## Tracks which players have already triggered each action level. var _warned: Dictionary = {} # player_id (int) → true var _kicked: Dictionary = {} # player_id (int) → true var _banned: Dictionary = {} # player_id (int) → ban_expiry_time (float) ## Accumulator for suspicion decay timer. var _decay_timer: float = 0.0 ## Optional kick/ban handler Callable. If set, it is invoked with ## (player_id, reason) when a player is kicked or banned. The caller is ## responsible for actually disconnecting or tracking the ban. var ban_handler: Callable = Callable() # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: pass func _process(delta: float) -> void: _decay_timer += delta if _decay_timer >= DECAY_INTERVAL: _decay_timer = 0.0 _decay_suspicion() # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- ## Set the max player speed (should match the server's sprint speed or ## the fastest any legitimate player can move). func set_max_speed(speed: float) -> void: _max_speed = max(0.1, speed) ## Return the current max-speed value used for speed-hack checks. func get_max_speed() -> float: return _max_speed # --------------------------------------------------------------------------- # Public API — report methods # --------------------------------------------------------------------------- ## Report a player's input RPC rate. Flags if rate_hz exceeds the limit. func report_input_rate(player_id: int, rate_hz: float) -> void: if _is_banned(player_id): return if rate_hz > DEFAULT_RATE_LIMIT_HZ: _add_suspicion( player_id, SUS_INPUT_RATE, "Input rate %.1f Hz exceeds limit (max %.1f Hz)" % [rate_hz, DEFAULT_RATE_LIMIT_HZ] ) ## Report a player's position delta over a given number of ticks. ## delta_pos: the Vector3 displacement since the last valid report. ## tick_delta: number of simulation ticks over which the movement occurred ## (typically 1 for per-tick checks, larger for infrequent reports). func report_movement(player_id: int, delta_pos: Vector3, tick_delta: int) -> void: if _is_banned(player_id): return if tick_delta <= 0: return var distance: float = delta_pos.length() var tick_time: float = 1.0 / 128.0 # simulation tick duration ## Max distance a player could legitimately travel in tick_delta ticks. var max_expected: float = _max_speed * SPEED_TOLERANCE * tick_time * tick_delta if distance > max_expected: _add_suspicion( player_id, SUS_SPEED_HACK, "Movement %.2f units exceeds expected max %.2f (%d ticks)" % [distance, max_expected, tick_delta] ) ## Report a player's look-direction snap. ## from: the previous tick's look direction (normalised Vector3). ## to: the current tick's look direction (normalised Vector3). ## Flags if the angle between them exceeds AIMBOT_SNAP_DEG. func report_look_snap(player_id: int, from: Vector3, to: Vector3) -> void: if _is_banned(player_id): return # Avoid normalisation issues with zero vectors var f_len_sq: float = from.length_squared() var t_len_sq: float = to.length_squared() if f_len_sq < 0.0001 or t_len_sq < 0.0001: return var dot: float = from.dot(to) / (sqrt(f_len_sq) * sqrt(t_len_sq)) var angle_deg: float = rad_to_deg(acos(clampf(dot, -1.0, 1.0))) if angle_deg > AIMBOT_SNAP_DEG: _add_suspicion( player_id, SUS_AIMBOT, "Look snap %.1f° exceeds max %.1f°" % [angle_deg, AIMBOT_SNAP_DEG] ) # --------------------------------------------------------------------------- # Public API — state queries # --------------------------------------------------------------------------- ## Return the current suspicion level for a player (0.0 – 1.0). func get_suspicion(player_id: int) -> float: return _suspicion.get(player_id, 0.0) ## Return true if the player is currently banned (ban still in effect). func is_banned(player_id: int) -> bool: return _is_banned(player_id) ## Remove all tracking data for a player (on disconnect / map change). func unregister_player(player_id: int) -> void: _suspicion.erase(player_id) _warned.erase(player_id) _kicked.erase(player_id) # NOTE: _banned entries persist so reconnecting within the ban window # still rejects. Use clear_bans() on map change or explicit unban. ## Clear all ban records (e.g., on server restart or map change). func clear_bans() -> void: _banned.clear() ## Clear all suspicion tracking (full reset). func reset_all() -> void: _suspicion.clear() _warned.clear() _kicked.clear() _banned.clear() _decay_timer = 0.0 # --------------------------------------------------------------------------- # Internal — suspicion management # --------------------------------------------------------------------------- ## Add suspicion for a player and take action if thresholds are crossed. ## reason is a human-readable string for log/kick/ban messages. func _add_suspicion(player_id: int, amount: float, reason: String) -> void: var current: float = _suspicion.get(player_id, 0.0) current = min(1.0, current + amount) _suspicion[player_id] = current player_suspicion_changed.emit(player_id, current) # Check thresholds in ascending order — only act on the highest newly # reached threshold to avoid spam (no re-warn on same violation). if current >= BAN_THRESHOLD and not _banned.has(player_id): _apply_ban(player_id, reason) elif current >= KICK_THRESHOLD and not _kicked.has(player_id): _apply_kick(player_id, reason) elif current >= WARN_THRESHOLD and not _warned.has(player_id): _apply_warn(player_id, reason) func _apply_warn(player_id: int, reason: String) -> void: _warned[player_id] = true var msg: String = "[AntiCheat] WARN — player %d: %s" % [player_id, reason] print(msg) # No disconnect; just a server-side log warning. func _apply_kick(player_id: int, reason: String) -> void: _kicked[player_id] = true var msg: String = "[AntiCheat] KICK — player %d: %s" % [player_id, reason] print(msg) player_kicked.emit(player_id, reason) if ban_handler.is_valid(): ban_handler.call(player_id, reason) func _apply_ban(player_id: int, reason: String) -> void: var expiry: float = Time.get_unix_time_from_system() + BAN_DURATION _banned[player_id] = expiry var msg: String = "[AntiCheat] BAN — player %d: %s (until UNIX %d)" % \ [player_id, reason, int(expiry)] print(msg) player_banned.emit(player_id, reason) if ban_handler.is_valid(): ban_handler.call(player_id, reason) ## Decay suspicion for all non-banned players. func _decay_suspicion() -> void: for player_id in _suspicion.keys(): if _is_banned(player_id): continue var current: float = _suspicion[player_id] - DECAY_AMOUNT if current <= 0.0: _suspicion.erase(player_id) _warned.erase(player_id) # Keep _kicked so they don't get a second chance immediately # after decay; only explicit unregister resets kicks. else: _suspicion[player_id] = current player_suspicion_changed.emit(player_id, current) ## Check if a player is currently banned (ban recorded and still in effect). func _is_banned(player_id: int) -> bool: if not _banned.has(player_id): return false var expiry: float = _banned[player_id] if Time.get_unix_time_from_system() >= expiry: # Ban expired — clean up _banned.erase(player_id) return false return true