Networking and Trust
Multiplayer games need animations to look consistent across all clients. When one player reloads, every other client should see the reload animation. When a player idles, they should not appear frozen to their teammates.
The naive approach is to fire a RemoteEvent from the owning client whenever an animation
plays. This works, but it opens problems: the client can send fake animation names, spam events
to lag the server, or claim animations the server never verified.
MotixNet is built around a specific model: the owning client sends compact intent packets automatically, the server validates and relays them, and other clients replay them locally. The client never sends behavior tables or raw animation IDs -- only a small integer hash.
The Architecture
Owning Client Server Other Clients
───────────── ────── ─────────────
controller:Play("Reload")
OnPlay fires
→ hash lookup: "Reload" = 4
→ encode: {hash=4, action=PLAY, ts=...}
→ RemoteEvent:FireServer(encoded)
← receive intent packet
← validate: session, rate, hash, staleness
← OnIntentAccepted fires
← relay encoded packet to all other clients
← receive relay
← decode: hash=4 → "Reload"
← controller:Play("Reload")
← OnIntentReceived fires
Two things to note:
-
The owning client sends intents automatically when
OnPlayorOnStopfires on its controller. No extra code is needed at the call site. -
Other clients receive relayed intents and automatically call
PlayorStopon their local controller. TheirOnIntentReceivedsignal fires afterward if you need to hook into the event.
The Hash Registry
Behavior tables are never sent over the network. Instead, both server and client register the same animation names at startup in the same order. Each registration assigns an integer hash. Intent packets carry only the hash.
-- SharedAnimations.lua
-- Required by both server and client. Same module, same order.
local Motix = require(ReplicatedStorage.Motix)
local HashReg = Motix.MotixNet.HashRegistry.new()
HashReg:Register("Idle") -- hash 0
HashReg:Register("Walk") -- hash 1
HashReg:Register("Run") -- hash 2
HashReg:Register("Fire") -- hash 3
HashReg:Register("Reload") -- hash 4
return HashReg
A fire intent that says "hash=4, action=PLAY" is unambiguous. The server looks up hash 4 in
its own registry and finds "Reload". There is nothing for the client to forge. If they send
an unregistered hash, the intent is rejected with UnknownAnimHash before anything happens.
Both server and client must register animation names in the same order. If the orders differ,
every hash on one side maps to a different name on the other side, and all intents are rejected
or misapplied. Always require the same shared ModuleScript. Never register conditionally or
in environment-specific order.
Server Setup
local SharedHashReg = require(ReplicatedStorage.SharedAnimations)
local Net = Motix.MotixNet.new(ServerController, SharedHashReg, {
TokensPerSecond = 30, -- intent refill rate
BurstLimit = 60, -- max burst before throttling
MaxIntentStaleness = 0.5, -- max seconds before an intent is rejected as stale
SnapshotInterval = 2.5, -- seconds between full state snapshots to clients
ReplicateState = true, -- send periodic snapshots for desync recovery
})
Net.OnIntentAccepted:Connect(function(player, animName, action)
-- action is "PLAY" or "STOP"
-- Use this to apply server-side effects tied to animation events.
-- For example, triggering a sound, applying damage, or logging metrics.
print(player.Name, action, animName)
end)
Net.OnIntentRejected:Connect(function(player, reason)
-- Log this for telemetry. Frequent RateLimited rejections from a specific player
-- may warrant investigation.
warn(player.Name, "intent rejected:", reason)
end)
Filtering Replication
By default MotixNet broadcasts to all players in the server. SetPlayerFilter lets you
restrict replication to a subset of players at runtime.
-- Only players on the blue team receive animation data for this character
Net:SetPlayerFilter(function(player)
return player.Team == Teams.Blue
end)
The filter applies to all outgoing data: server intents, relayed client intents, and periodic state snapshots. Players excluded by the filter receive nothing for that character.
To remove the filter and resume broadcasting to everyone:
Net:SetPlayerFilter(nil)
The filter composes with ownership exclusion. If the MotixNet instance has an OwningPlayer,
that player is already excluded from snapshots. The filter is applied first and the ownership
exclusion runs on the result.
A common pattern is to only replicate a character's animations to players who are in the same squad, team, or proximity zone. Set the filter when the team assignment is known and update it whenever team membership changes.
Client Setup
local SharedHashReg = require(ReplicatedStorage.SharedAnimations)
-- Owning client (the player controlling this character)
local OwningNet = Motix.MotixNet.new(OwningController, SharedHashReg)
-- Intents are sent automatically. No additional code needed at Play/Stop call sites.
-- Other clients (observing this character)
local ObserverNet = Motix.MotixNet.new(ObserverController, SharedHashReg)
ObserverNet.OnIntentReceived:Connect(function(animName, action, sequenceNumber)
-- Called after the intent has already been applied to the controller.
-- Use this if you need to hook into the event for effects, audio, etc.
end)
ObserverNet.OnSnapshotReceived:Connect(function(payload)
-- Called when a full state snapshot is received from the server.
-- Reconciliation is applied automatically. This signal is informational.
end)
What Gets Validated
When an intent packet arrives on the server, MotixNet checks the following:
Session validity. The sending player must have an active session. Players who have left the
game or whose session was not registered are rejected with SessionInactive.
Rate limiting. A token bucket prevents any player from sending intents faster than
TokensPerSecond. Burst fire is allowed up to BurstLimit tokens. When tokens run out, the
intent is rejected with RateLimited.
Hash validity. The animation hash is resolved against the server's registry. An unknown
hash gives UnknownAnimHash.
Staleness. The intent timestamp is compared against the server's current time. If the
intent is older than MaxIntentStaleness seconds, it is rejected with StaleIntent.
Ownership. If the MotixNet instance was created with an OwningPlayer, only that player
may send intents for the associated character. Mismatches are silently dropped with a server
warning.
| Rejection reason | Meaning |
|---|---|
SessionInactive | Player not registered in the session table |
RateLimited | Token bucket exhausted |
UnknownAnimHash | Hash not found in server HashRegistry |
StaleIntent | Timestamp older than MaxIntentStaleness |
InvalidAction | Action byte not recognized |
PlayerNotFound | Player not found for the incoming connection |
Authority Modes
MotixNet supports three authority modes set via NetworkConfig.Mode. Use
Motix.MotixNet.Enums.NetworkMode rather than raw strings.
ClientAuthoritative (default)
Clients send intents. The server validates and relays them. This is the standard model.
local Net = Motix.MotixNet.new(ServerController, SharedHashReg, {
-- Mode defaults to ClientAuthoritative
})
ServerAuthority
Only server code may trigger animations. Any client intent is silently dropped. Use this for NPC animations, cinematic sequences, or any character that players should never be able to animate directly.
local Net = Motix.MotixNet.new(ServerController, SharedHashReg, {
Mode = Motix.MotixNet.Enums.NetworkMode.ServerAuthority,
})
-- The server plays animations and they are relayed to all clients automatically.
ServerController:Play("NPCPatrol")
SharedAuthority
Both client and server may send intents. Client intents go through the full validation pipeline. Server calls bypass validation entirely. Use this when player-controlled and server-controlled animations share the same controller and registry.
local Net = Motix.MotixNet.new(ServerController, SharedHashReg, {
Mode = Motix.MotixNet.Enums.NetworkMode.SharedAuthority,
})
Desync Recovery
When ReplicateState is true, the server sends a full animation state snapshot to all
non-owning clients every SnapshotInterval seconds. Clients track sequence numbers on the
intent stream. When a snapshot arrives, the client checks whether its local sequence matches
the server's. If there is a mismatch, the snapshot is applied as a reconciliation update.
For late-joining players, the server sends a full state snapshot immediately on join. The client applies this to restore the current animation state without replaying every intent from the beginning of the session.
Tuning Rate Limits
TokensPerSecond and BurstLimit control how often a player can send intents.
A reasonable starting point for typical character animation is TokensPerSecond = 30 with
BurstLimit = 60. This allows one intent every 33ms sustained and a burst of 60 before
throttling kicks in. For games with rapid weapon cycling or many animation events per second,
raise both values proportionally.
If you are seeing RateLimited rejections from players you trust, increase TokensPerSecond.
If you are seeing deliberate spam attempts, lower BurstLimit.
What MotixNet Cannot Do
MotixNet validates that animation intents are structurally correct and came from a session
player at a valid rate. It does not validate that the player should be allowed to play a
given animation. Whether a player is alive, in range, or holding the right tool is your
responsibility to check before or after calling Play.
MotixNet also does not prevent a client from playing animations locally without sending intents. A cheating client could display arbitrary local animations. MotixNet only controls what the server acknowledges and relays to other clients.