Skip to main content

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:

  1. The owning client sends intents automatically when OnPlay or OnStop fires on its controller. No extra code is needed at the call site.

  2. Other clients receive relayed intents and automatically call Play or Stop on their local controller. Their OnIntentReceived signal 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.

Registration order matters

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.

Use case: team-restricted characters

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 reasonMeaning
SessionInactivePlayer not registered in the session table
RateLimitedToken bucket exhausted
UnknownAnimHashHash not found in server HashRegistry
StaleIntentTimestamp older than MaxIntentStaleness
InvalidActionAction byte not recognized
PlayerNotFoundPlayer 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.