Client visuals and kills
The server owns the truth: where an enemy is, what state it is in, whether it killed you. The client owns how that looks. The package gives each side one piece and leaves the remote between them to you, because every game shapes its remotes differently.
The two pieces
Broadcaster watches an agent on the server. It fires StateChanged on
every state change, EventSent for everything the agent emits, and, at a rate
you choose, PositionChanged with the rig's pivot position and a yaw. Snapshot() is for a
player who just joined (see Snapshots for a player who joins). Make the broadcaster before agent:Start() so the first state is relayed.
Replica lives on the client with a rig and a table of client states, the
Visuals. SetState(name) runs the old state's Exit and the new one's Enter;
Receive(name, payload) hands an event to your On(name, ...) handlers; SetPosition gives it
somewhere to smooth toward. Visuals have the same Enter, Update, Exit shape as a brain, with
the replica in place of the agent. That is your custom client logic: a highlight while it hunts,
a heartbeat sound while it advances, a camera shake on a scream.
-- server. Id: the rig itself while every client already has it; a string key under streaming.
local broadcaster = VluxyAI.Sync.Broadcaster.new(agent, { PositionRate = 0 })
broadcaster.StateChanged:Connect(function(state)
syncRemote:FireAllClients({ Kind = "State", Id = model, State = state })
end)
broadcaster.EventSent:Connect(function(name, payload)
syncRemote:FireAllClients({ Kind = "Event", Id = model, Event = name, Payload = payload })
end)
-- client
local replica = VluxyAI.Sync.Replica.new({
Entity = model,
Visuals = {
Hunt = {
Enter = function(replica) replica.Data.Glow.Enabled = true end,
Exit = function(replica) replica.Data.Glow.Enabled = false end,
},
},
})
syncRemote.OnClientEvent:Connect(function(message)
if message.Kind == "State" then
replica:SetState(message.State)
elseif message.Kind == "Event" then
replica:Receive(message.Event, message.Payload)
end
end)
Snapshots for a player who joins
A client that joins mid-game needs every agent's current state, and an agent that is not
changing state (a statue waiting for someone to look away) will never send one on its own. Do
not send snapshots from Players.PlayerAdded: that can fire before the player's client script
has connected to the remote, and the message is lost. Let the client ask once it is listening:
-- server
syncRemote.OnServerEvent:Connect(function(player, message)
if message == "Ready" then
for model, broadcaster in broadcasters do
syncRemote:FireClient(player, { Kind = "Snapshot", Id = model, Snapshot = broadcaster:Snapshot() })
end
end
end)
-- client, after connecting OnClientEvent
syncRemote:FireServer("Ready")
replica:ApplySnapshot(message.Snapshot) puts the replica in that state. The playground does
exactly this.
Ids over the wire
The playground sends the rig itself as the id, which is fine while the rig has replicated to
every client (the test place has streaming off and spawns before anyone joins). With
StreamingEnabled, an instance that has not streamed in arrives as nil; send a string key
instead (a SyncId attribute, a name) and resolve the rig lazily on the client.
Mirror or puppet
Mirror is the simple mode and what the playground uses. The server rig is visible and
replicates through Roblox as any NPC does. The replica only runs visuals on it. Send states and
events; leave PositionRate at 0.
Puppet is the mode from the design plan: the server keeps a logical position, usually with the
CFrame mover on an invisible collider, and each client draws its own rig.
VluxyAI.Presets.Puppet(anchor, { Navmesh = { AgentRadius = 4 }, Mover = { Speed = 10 } })
builds that server half: a navmesh, the CFrame mover with the anchor's pivot as its floor point,
and a Part anchor wrapped in an invisible Model.
Make the replica with Puppet = true, stream positions at ten or so a second over an unreliable
remote, and the replica smooths its rig toward each sample so the rate never shows. Kills are
still decided on the server from the server position; the client draws the enemy slightly behind,
and a hard cut hides the gap.
A puppet has no agent and, anchored, no physics velocity, so animate it from its own motion:
VluxyAI.Locomotion.AttachReplica(replica, { Animations = { Idle = ..., Walk = ..., Run = ... }, WalkSpeed = 10, RunSpeed = 22 }) measures the rig's smoothed pivot speed every frame, blends idle,
walk and run, and scales their playback to match. It is undone when the replica is destroyed.
Headless
A puppet's anchor is still one Instance per enemy on the server, and under Workspace it replicates
to every client. Headless drops it: the agent has no Entity at all and its pivot lives in a
Record mover, a CFrame in a table.
VluxyAI.Presets.Headless({ Position = spawn, Pathfinder = graph, Mover = { Speed = 10 } }) builds
it, or VluxyAI.new():UseMover(VluxyAI.Movers.Record.new({ Position = spawn })) by hand.
A Record moves only inside agent:Step, so start the agent with Start(true) and step it from
your own scheduler, as many agents per frame as your budget allows; or Start() it to tick on
Heartbeat as usual. agent:GetPivot() is its position and yaw, and the senses, doors and the
Broadcaster all read it, so a headless agent streams to puppet replicas like any other, or you
batch GetPivot into your own remote. Rig-only parts (Locomotion, Voice's default playback, the
Debug visuals) say so when given a headless agent. Graph and Navmesh both plan without a
body; Straight raycasts and works too.
Kills
Kill gives an agent two ways to finish a character, both decided on the server and
both announced as a "Kill" event with { Kind, Victim, Duration }:
- Live:
VluxyAI.Combat.Kill.Live(agent, character, { KillerAnimation, VictimAnimation })takes the victim's root for the server, holds them in a pose next to the killer while both rigs play their animation, and kills them when it ends. Everyone sees it. - Black box:
VluxyAI.Combat.Kill.BlackBox(agent, character)announces at once and kills after a short delay. The client cuts to black on the event and the death happens behind it. For a game where nobody dies, passFinish = function(victim) ... end(respawn, teleport): it runs after the delay instead of the kill, and the event is the same.
Both return a handle with Finished and Cancel. A kill that was announced and then cancelled
(the attack state was left early, the killer was destroyed, someone else got the victim first)
emits "KillCancelled" so the client can take its screen down. On the client:
replica:On("Kill", function(event)
if event.Victim ~= Players.LocalPlayer.Character then
return
end
if event.Kind == "BlackBox" then
showBlackScreen(event.Duration + 1)
else
lockCameraOn(replica.Entity, event.Duration)
end
end)
The playground's Stalker finishes you with a live kill and its Weeping Angel with a black-box one;
examples/Client/Kills.luau is the presentation.
Anything else
agent:Emit("Scream") from a state, replica:On("Scream", ...) on the client. Emit is the
general channel; kills are just its first customer.