The Problem With Trust
WEBFISHING is a great game. We love it, and Reel Talk owes a lot to its design. But its multiplayer networking had a critical vulnerability: external actors could send server commands without being in the game. That meant harassment tools could kick players, spam chat, and disrupt servers without anyone being able to identify or stop the attacker. For a game whose community is disproportionately LGBTQ+ players seeking a safe space, this was devastating.
When we started building Reel Talk's multiplayer layer, we made a decision early: every single RPC function would validate its sender before processing anything. No exceptions. This is not optional security hardening -- it is the foundation that every network interaction sits on top of.
MessageValidator: The Gatekeeper
Our solution is a singleton called MessageValidator that sits between every incoming RPC and the game logic that processes it. It does three things: confirms the sender is a registered player, checks per-action rate limits, and logs security events for debugging.
The core of it is the validate_rpc() function, which every @rpc function calls at the top before doing anything else.
## MessageValidator.gd
var active_players: Dictionary = {} # steam_id -> peer_id
var _peer_to_steam: Dictionary = {} # peer_id -> steam_id (reverse lookup)
var rate_limits: Dictionary = {} # peer_id -> { action -> [timestamps] }
func validate_rpc(action: String) -> bool:
var sender_id: int = multiplayer.get_remote_sender_id()
# Host (peer ID 1) and local calls (peer ID 0) are always valid
if sender_id == 0 or sender_id == 1:
return true
if not is_valid_sender(sender_id):
push_warning("[MessageValidator] SECURITY: Rejected RPC '%s' from unknown sender: %d"
% [action, sender_id])
_log_security_event("unknown_sender", action, sender_id)
return false
if not check_rate_limit(sender_id, action):
push_warning("[MessageValidator] SECURITY: Rate limited '%s' from sender: %d"
% [action, sender_id])
_log_security_event("rate_limited", action, sender_id)
return false
return true
The pattern is simple but strict. A sender is "valid" if they went through the normal Steam authentication flow and got registered in the active_players dictionary. If someone sends an RPC without being in that dictionary -- which is exactly what the WEBFISHING exploits did -- it gets rejected and logged immediately. There is no fallback, no "maybe they are reconnecting" grace period. Unknown sender means rejected.
Per-Action Rate Limiting
Sender validation catches external attackers, but rate limiting catches abuse from players who are legitimately connected. A malicious client could flood the server with chat messages, fish catch claims, or emote spam to degrade the experience for everyone else.
We use a sliding window rate limiter with custom limits per action type. The implementation filters out timestamps older than the window, then checks if the remaining count exceeds the threshold.
const MAX_MESSAGES_PER_SECOND: int = 60
const RATE_LIMIT_WINDOW: float = 1.0
const CUSTOM_RATE_LIMITS: Dictionary = {
"chat_message": 5,
"emote": 3,
"fish_caught": 10,
"character_sync": 5,
"bobber_update": 60,
"scene_ready": 1,
}
func check_rate_limit(sender_id: int, action: String) -> bool:
if not rate_limits.has(sender_id):
return false
var now: float = Time.get_ticks_msec() / 1000.0
var action_times: Array = rate_limits[sender_id].get(action, [])
# Remove timestamps outside the window
action_times = action_times.filter(func(t): return now - t < RATE_LIMIT_WINDOW)
var max_allowed: int = CUSTOM_RATE_LIMITS.get(action, MAX_MESSAGES_PER_SECOND)
if action_times.size() >= max_allowed:
return false
action_times.append(now)
rate_limits[sender_id][action] = action_times
return true
The limits are tuned to gameplay reality. Chat messages are capped at 5 per second because nobody types that fast -- if you are hitting that limit, you are a bot. Bobber position updates are allowed up to 60 per second because the fishing sync runs at 20Hz and we want headroom. The scene_ready action is limited to 1 per second because it should only fire once when a player joins.
The default fallback of 60 messages per second per action is generous enough for legitimate play while still preventing a single client from flooding the network.
Host Authority and Catch Validation
Sender validation and rate limiting prevent unauthorized access and spam, but they do not prevent a modified client from claiming to have caught a legendary fish every cast. For that, we use a host authority model where the host validates all gameplay-significant actions.
When a player catches a fish, the client sends the catch data to the host via an RPC. The host validates the claim against its own fish spawn data -- it knows which fish are available at each spot, what the valid size ranges are, and what rarity tiers are possible. If the catch does not match what the host knows should be possible, it gets rejected.
## FishingMultiplayerSync.gd
@rpc("any_peer", "call_remote", "reliable")
func _rpc_receive_fish_caught(
fish_id: String, display_name: String,
rarity: int, size: float, value: int
) -> void:
if not MessageValidator.validate_rpc("fish_caught"):
return
var sender_id: int = multiplayer.get_remote_sender_id()
if _is_host():
if not _validate_catch(fish_id, size):
push_warning("[FishingMultiplayerSync] SECURITY: Invalid catch from peer %d: %s"
% [sender_id, fish_id])
return
host_record_catch(sender_id, fish_id, size, rarity)
remote_player_caught.emit(sender_id, fish_id, display_name, rarity, size, value)
This layered approach means an attacker needs to clear three hurdles: they must be a registered player (MessageValidator), they must not be rate limited, and their gameplay claims must pass host-side validation. Each layer independently blocks a different class of exploit.
Content Validation
Network security is not just about preventing unauthorized commands. It also means validating the content of legitimate messages. Our MessageValidator sanitizes chat messages and player names to prevent rendering exploits.
const MAX_CHAT_LENGTH: int = 500
func validate_chat_message(message: String) -> String:
if message.is_empty():
return ""
if message.length() > MAX_CHAT_LENGTH:
message = message.substr(0, MAX_CHAT_LENGTH)
message = message.strip_edges()
message = message.replace("\u0000", "") # Strip null bytes
return message
Null byte injection, oversized strings, and control characters can all cause crashes or visual exploits in the chat UI. Truncating to 500 characters and stripping control characters costs almost nothing at runtime and eliminates an entire class of potential problems.
Security Logging
When a security event fires, we do not just reject the message and move on. We log it to a rolling buffer that can be inspected in debug builds or sent to analytics. This is critical for tuning rate limits -- if legitimate players are getting rate limited during normal play, the limits are too tight. If exploit attempts are getting through, they are too loose.
var security_log: Array = []
const MAX_LOG_SIZE: int = 100
func _log_security_event(event_type: String, action: String, sender_id: int) -> void:
var event: Dictionary = {
"timestamp": Time.get_unix_time_from_system(),
"type": event_type,
"action": action,
"sender_id": sender_id,
}
security_log.append(event)
if security_log.size() > MAX_LOG_SIZE:
security_log.pop_front()
The log is capped at 100 entries with a simple FIFO eviction. In production, this gives us enough history to diagnose an ongoing attack without accumulating memory over long server sessions.
Lessons Learned
Building Reel Talk's security layer taught us a few things we wish we had known going in.
First, security must be a requirement, not a feature. If you bolt it on after the networking layer is built, you will miss RPCs. Every @rpc function in our codebase starts with if not MessageValidator.validate_rpc(): return. There are no exceptions. We enforce this in code review.
Second, rate limits should be per-action, not global. A single global limit of 60 messages per second would allow a client to burn its entire budget on chat spam and still look "within limits." Per-action limits mean that spamming chat does not affect fishing sync, and vice versa.
Third, host authority is the only reliable anti-cheat for P2P games. Client-side validation can be bypassed by modifying the client. The host has ground truth about fish spawns, currency, and catch validation. Clients request actions; the host decides whether they are valid.
For any indie developer building a multiplayer Godot game -- especially one where community safety matters -- these patterns are not optional. They are the minimum. The cost of implementing them upfront is a fraction of the cost of dealing with harassment campaigns after launch.