@hexium-softworks/replicationservice
v0.1.0
Published
Game-agnostic Nevermore service for server-authoritative structured state replication.
Maintainers
Readme
ReplicationService
Server-authoritative state replication for Nevermore Roblox games.
ReplicationService gives gameplay packages a clean state API instead of making each package create RemoteEvents, send snapshots, track audiences, validate payloads, and clean everything up by hand. The server owns the truth. Clients receive read-only snapshots and patches for the states they are allowed to see.
Good fits:
- player data shown to the owning player
- round, match, or lobby state
- objectives, quests, tasks, and tutorials
- cooldowns and timers
- hotbar, loadout, and inventory UI state
- status effects and buffs
- interactable and world-object state
- VFX/world cues such as positions, transforms, colors, and ranges
- public player info
- global or team UI state
Not this package:
- persistence
- DataStore/ProfileService integration
- combat, inventory, currency, or quest rules
- UI rendering
- client prediction
- cross-server messaging
- general RPC or command handling
Gameplay requests still belong to the owning services. A client should ask
ShopService to buy an item; ShopService validates the request, updates
authoritative state, and then ReplicationService replicates the result.
Install
pnpm add @hexium-softworks/replicationserviceThis package follows Nevermore conventions:
- https://quenty.github.io/NevermoreEngine/docs/architecture/
- https://quenty.github.io/NevermoreEngine/docs/architecture/servicebag/
- https://quenty.github.io/NevermoreEngine/api/Remoting/
- https://quenty.github.io/NevermoreEngine/api/Observable/
- https://quenty.github.io/NevermoreEngine/api/Promise/
Setup
Server service:
local require = require(script.Parent.loader).load(script)
local ReplicationService = require("ReplicationService")
local PlayerDataService = {}
PlayerDataService.ServiceName = "PlayerDataService"
function PlayerDataService:Init(serviceBag)
self._replicationService = serviceBag:GetService(ReplicationService)
end
return PlayerDataServiceClient service:
local require = require(script.Parent.loader).load(script)
local ReplicationServiceClient = require("ReplicationServiceClient")
local PlayerHudController = {}
PlayerHudController.ServiceName = "PlayerHudController"
function PlayerHudController:Init(serviceBag)
self._replicationService = serviceBag:GetService(ReplicationServiceClient)
end
return PlayerHudControllerUse ServiceBag:GetService() for both services. Do not call methods directly
on the module table.
Mental Model
On the server, create a state:
local state = replicationService:CreateState({
Id = "RoundState",
InitialState = {
Phase = "Waiting",
TimeRemaining = 0,
},
Audience = replicationService.Audiences.All,
})Mutate it on the server:
state:Set("Phase", "Playing")
state:Set("TimeRemaining", 120)
state:Increment("TimeRemaining", -1)Read it on the client:
replicationServiceClient:PromiseState("RoundState"):Then(function(roundState)
maid:GiveTask(roundState:Observe("TimeRemaining"):Subscribe(function(timeRemaining)
timerLabel.Text = tostring(timeRemaining)
end))
end)The client cannot call Set, Increment, Delete, or Batch. Client objects
are read-only caches.
Common Use Cases
Player Data
Use this for profile fields the owning player is allowed to see. Persistence and profile reconciliation stay in your player data package.
Server:
local clientState = replicationService:CreateState({
Id = `PlayerData:{player.UserId}`,
InitialState = {
Currency = {
Coins = profile:Get("Currency.Coins"),
Gems = profile:Get("Currency.Gems"),
},
Progression = {
Level = profile:Get("Progression.Level"),
Experience = profile:Get("Progression.Experience"),
RequiredExperience = profile:Get("Progression.RequiredExperience"),
},
},
Audience = player,
})
profile:Increment("Currency.Coins", 100)
clientState:Set("Currency.Coins", profile:Get("Currency.Coins"))Client:
replicationServiceClient:PromiseState(`PlayerData:{localPlayer.UserId}`)
:Then(function(playerData)
maid:GiveTask(playerData:Observe("Currency.Coins"):Subscribe(function(coins)
coinsLabel.Text = tostring(coins)
end))
maid:GiveTask(playerData:ObserveSelector(function(snapshot)
return snapshot.Progression.Experience / snapshot.Progression.RequiredExperience
end):Subscribe(function(progress)
xpBar.Size = UDim2.fromScale(progress, 1)
end))
end)Only put approved client-facing fields into the replicated state. Do not mirror the whole private profile unless the whole profile is safe for that player to see.
Round Or Match State
Use a global audience for state all players should see.
local roundState = replicationService:CreateState({
Id = "RoundState",
InitialState = {
Phase = "Intermission",
TimeRemaining = 30,
MapName = nil,
},
Audience = replicationService.Audiences.All,
})
roundState:Batch(function(transaction)
transaction:Set("Phase", "Playing")
transaction:Set("TimeRemaining", 180)
transaction:Set("MapName", "Factory")
end)Client:
replicationServiceClient:PromiseState("RoundState"):Then(function(roundState)
maid:GiveTask(roundState:Observe("Phase"):Subscribe(function(phase)
phaseLabel.Text = phase
end))
maid:GiveTask(roundState:Observe("TimeRemaining"):Subscribe(function(seconds)
timerLabel.Text = tostring(seconds)
end))
end)Objectives And Quests
Use one state per player when visibility is personal.
local objectiveState = replicationService:CreateState({
Id = `Objectives:{player.UserId}`,
InitialState = {
Active = {},
TrackedId = nil,
},
Audience = player,
})
objectiveState:Set({ "Active", questId }, {
Title = "Restore Power",
Current = 1,
Required = 3,
})
objectiveState:Set("TrackedId", questId)Client:
objectiveState:ObserveSelector(function(snapshot)
local trackedId = snapshot.TrackedId
return if trackedId then snapshot.Active[trackedId] else nil
end):Subscribe(function(objective)
if objective then
objectiveLabel.Text = `{objective.Title}: {objective.Current}/{objective.Required}`
end
end)Cooldowns And Timers
Replicate coarse state, not per-frame values unless you really need them.
local cooldownState = replicationService:CreateState({
Id = `Cooldowns:{player.UserId}`,
InitialState = {
Abilities = {},
},
Audience = player,
})
cooldownState:Set({ "Abilities", "Dash" }, {
ReadyAt = workspace:GetServerTimeNow() + 4,
Duration = 4,
})The client can render a local countdown from ReadyAt and Duration without
the server sending a patch every frame.
Hotbar And Inventory UI
Replicate UI-ready state. Keep authoritative item ownership and equip rules in your inventory service.
local hotbarState = replicationService:CreateState({
Id = `Hotbar:{player.UserId}`,
InitialState = {
Slots = {
[1] = { ItemId = "WoodSword", Count = 1 },
[2] = { ItemId = "HealthPotion", Count = 3 },
},
SelectedSlot = 1,
},
Audience = player,
})
hotbarState:Set({ "Slots", 2, "Count" }, 2)
hotbarState:Set("SelectedSlot", 2)Status Effects
local statusState = replicationService:CreateState({
Id = `StatusEffects:{player.UserId}`,
InitialState = {
Effects = {},
},
Audience = player,
})
statusState:Set({ "Effects", "Poison" }, {
Stacks = 2,
ExpiresAt = workspace:GetServerTimeNow() + 8,
})
statusState:Delete({ "Effects", "Poison" })Interactables And World Objects
Use explicit audiences when only nearby players should see a state.
local generatorState = replicationService:CreateState({
Id = "World.Generator.A",
InitialState = {
Powered = false,
Health = 100,
Prompt = "Repair",
},
Audience = replicationService.Audiences.None,
})
zone.PlayerEntered:Connect(function(player)
generatorState:AddPlayer(player)
end)
zone.PlayerLeft:Connect(function(player)
generatorState:RemovePlayer(player)
end)
generatorState:Set("Powered", true)Removing a player from the audience destroys that client copy and stops future updates.
VFX And World Cues
ReplicationService can carry immutable Roblox value datatypes. This is useful for VFX cues or world state that needs positions, transforms, colors, and ranges.
local stormState = replicationService:CreateState({
Id = "WorldEvent.Storm",
InitialState = {
Active = false,
Center = Vector3.zero,
Radius = 0,
Color = Color3.fromRGB(120, 180, 255),
},
Audience = replicationService.Audiences.All,
})
stormState:Batch(function(transaction)
transaction:Set("Active", true)
transaction:Set("Center", Vector3.new(100, 12, -40))
transaction:Set("Radius", 80)
transaction:Set("Color", Color3.fromRGB(80, 140, 255))
end)For one-shot effects, keep using an owning VFX service or remote. This package is better for replicated state that has a current value.
Team Or Group UI
ReplicationService does not know what a team, party, or squad is. Let your team/party service decide membership and feed ReplicationService players.
local teamState = replicationService:CreateState({
Id = `Team:{teamId}`,
InitialState = {
Score = 0,
Objective = "Capture",
},
Audience = {},
})
for _, player in teamService:GetPlayers(teamId) do
teamState:AddPlayer(player)
end
teamService.PlayerAddedToTeam:Connect(function(player, changedTeamId)
if changedTeamId == teamId then
teamState:AddPlayer(player)
end
end)Server API
Create A State
local state = replicationService:CreateState({
Id = "UniqueStateId",
InitialState = {},
Audience = replicationService.Audiences.None,
})Id must be unique while the state exists. InitialState must pass validation.
Audience can be:
replicationService.Audiences.AllreplicationService.Audiences.None- one
Player - an array of
Playerinstances
Mutate State
state:Get("Path.To.Value")
state:GetSnapshot()
state:Set("Path.To.Value", 10)
state:Update("Path.To.Value", function(value)
return value + 1
end)
state:Increment("Path.To.Value", 1)
state:Delete("Path.To.Value")Use Delete(path) instead of Set(path, nil).
Batch Changes
state:Batch(function(transaction)
transaction:Set("Phase", "Playing")
transaction:Increment("Score.Red", 1)
transaction:Delete("PendingVote")
end)Batches publish one coherent revision. If the callback errors, no staged changes are committed.
Observe On The Server
maid:GiveTask(state:Observe("Score.Red"):Subscribe(function(score)
scoreCache.Red = score
end))Server observers are useful for package-to-package integration. Avoid direct
print in production package code; route diagnostics through your logger.
Client API
Get Or Wait For State
local state = replicationServiceClient:GetState("RoundState")
replicationServiceClient:PromiseState("RoundState"):Then(function(roundState)
-- ready to read
end)GetState returns nil until the server has authorized and initialized that
state. PromiseState is usually friendlier for UI startup.
Read Values
local phase = state:Get("Phase")
local snapshot = state:GetSnapshot()Snapshots are cloned and frozen. Accidental client mutation fails locally, but security still comes from the server owning all authoritative state.
Observe Values
maid:GiveTask(state:Observe("Phase"):Subscribe(function(phase)
phaseLabel.Text = phase
end))
maid:GiveTask(state:ObserveSnapshot():Subscribe(function(snapshot)
render(snapshot)
end))
maid:GiveTask(state:ObserveSelector(function(snapshot)
return snapshot.Currency.Coins
end):Subscribe(function(coins)
coinsLabel.Text = tostring(coins)
end))Observers emit the current value once the state is ready, emit again only when the observed value changes, and complete when the state is destroyed.
Paths
Paths can be dotted strings or arrays:
state:Set("Currency.Coins", 100)
state:Set({ "Currency", "Coins" }, 100)Arrays are useful when a segment is dynamic:
state:Set({ "Inventory", itemId, "Count" }, 3)Path segments are normalized internally.
Security
Clients cannot ask for arbitrary state and receive it. The server decides visibility by audience membership and sends only authorized states.
Do not put secrets into replicated state. If a client receives it, the client can inspect it. Use separate state objects when different players should see different data.
Do not use ReplicationService as an RPC command layer. Client requests should go to the service that owns the action:
-- Good shape:
-- Client -> ShopService:RequestPurchase(itemId)
-- ShopService validates
-- ShopService updates profile
-- ShopService updates replicated player stateValidation And Supported Values
ReplicationService rejects malformed state before replication. It validates:
- state ids
- paths
- replicated value types
- cyclic tables
- non-finite numbers
- excessive depth, node count, string length, and path length
- duplicate state ids
- mutations after destroy
Supported values:
nil, booleans, finite numbers, and strings- structured tables with string or numeric keys
Vector2,Vector3,CFrame,Color3UDim,UDim2,RectNumberRange,NumberSequence,ColorSequenceBrickColor
Rejected values include Instance, functions, threads, buffers, cyclic tables,
and mutable userdata.
Performance Notes
ReplicationService sends one initial snapshot per authorized player and then patches for ordinary mutations.
Prefer:
- batching related changes
- coarse timer data such as
ReadyAtinstead of per-frame updates - separate state objects for different visibility groups
- selectors for UI values that should only re-render when their result changes
Avoid:
- replicating huge tables as one state
- sending high-frequency per-frame state unless you have profiled it
- putting private server-only data in client-visible state
Lifecycle And Cleanup
Every state has Destroy(). Destroying a server state sends destroy messages to
authorized clients and removes registry entries. Removing a player from an
audience destroys that client copy.
Own client subscriptions with a Maid:
maid:GiveTask(state:Observe("Value"):Subscribe(function(value)
-- update UI
end))Reference
ReplicationService:
| Method | Description |
| --- | --- |
| CreateState(config) | Creates a server-owned state. |
| GetState(stateId) | Returns a server state or nil. |
| GetStateIds() | Returns sorted state ids. |
| ObserveStateIds() | Observes server state id registry changes. |
ReplicatedState:
| Method | Description |
| --- | --- |
| Get(path?) | Gets a cloned value. |
| GetSnapshot() | Gets a frozen snapshot. |
| Set(path, value) | Sets a value and publishes a patch. |
| Update(path, callback) | Computes and sets a value. |
| Increment(path, amount?) | Adds to a numeric value. |
| Delete(path) | Removes a value. |
| Batch(callback) | Publishes grouped changes atomically. |
| AddPlayer(player) | Adds an explicit audience member. |
| RemovePlayer(player) | Removes an explicit audience member. |
| SetAudience(audience) | Replaces audience membership. |
| Observe(path) | Observes a path locally on the server. |
| ObserveSnapshot() | Observes server snapshots. |
| Destroy() | Destroys state and client copies. |
ReplicationServiceClient:
| Method | Description |
| --- | --- |
| GetState(stateId) | Returns an authorized local state or nil. |
| PromiseState(stateId) | Resolves when an authorized state is ready. |
| ObserveState(stateId) | Observes local state availability. |
| GetStateIds() | Returns sorted authorized state ids. |
| ObserveStateIds() | Observes authorized state id changes. |
ReadonlyReplicatedState:
| Method | Description |
| --- | --- |
| IsReady() | Returns whether initial data is available. |
| PromiseReady() | Resolves with the state once ready. |
| Get(path?) | Gets a cloned value. |
| GetSnapshot() | Gets a frozen snapshot. |
| Observe(path) | Observes a path. |
| ObserveSnapshot() | Observes snapshots. |
| ObserveSelector(selector) | Observes computed values with distinct filtering. |
| Destroy() | Destroys the local state object. |
