How syncing works
The server builds a tree from disk and pushes it into Studio. Studio never sends anything back about the place, so there is no merge and no conflict to resolve.
One direction
Files are the source of truth. The server scans the project into an instance tree, writes sourcemap.json, and sends the tree to any connected plugin. The plugin's only job is to make the place match.
Nothing you do in Studio is read, exported, or written back to disk. If you edit a synced instance by hand, the next patch that touches it will simply put it back.
The one thing that does travel back is messages you send on purpose: events you fire, console output during a playtest, and the start and end of a playtest. They go to your hook scripts, never into the project. See Studio events.
Ownership
This is the part that decides whether RoFlux may delete something. Every node in the tree is one of three kinds.
| Kind | Created | Properties applied | Owns its children |
|---|---|---|---|
| managed | yes | yes | yes |
| passthrough | only implicit folders | yes | no |
| reference | never | never | never |
managed
Anything your project actually declares: a script, a folder, an instance from an .inst.json, a child in a meta.json. RoFlux creates it, keeps its properties, attributes and tags in line, and owns its children.
passthrough
Services, and the containers on the path to your content. Their properties are applied if you declared any, but their children are not owned. A Folder you made by hand in Workspace is never touched, because Workspace is passthrough. Services are never created. If a place has no such service, RoFlux reports it and moves on. Intermediate containers that do not exist are created as plain Folders.
reference
$FromModel nodes. Never created, never modified, never deleted, and stripped out of the sync payload before it is even sent. They exist only so the sourcemap is complete.
Deletion
Two rules, and they follow from ownership.
- Delete a file, and the instance goes. The next rebuild diffs it out of the tree and the plugin destroys it.
- Drift inside a managed tree is cleaned up. On a full sync, any child of a managed instance that your project does not declare is removed.
The second rule stops at the boundary of what you declared. Unmanaged siblings in the same parent survive, because the parent is passthrough:
ReplicatedStorage -- passthrough, children not owned
├─ Shared -- managed, children owned
│ ├─ Greeting -- declared, kept
│ └─ StrayThing -- not declared, DELETED
└─ SomeoneElsesFolder -- not declared, but left alone
Moving things
Changing a $Parent is a move. The copy in the new place is created and the old one is deleted, even when the old parent is a service that nothing else in your project uses.
This also works for moves made while you were not connected. The plugin keeps a short list of the top level instances it created for each project. It saves this list as an attribute named RoFluxState on ServerStorage, so it stays with the place file and adds nothing to the Explorer. When you connect again, anything on that list that your project no longer has is deleted, and it shows up in the review first.
The list starts the first time you sync with this version of the plugin. Leftovers made before that are not on it, so delete those by hand once.
An instance's class cannot be changed in place. If a declared class no longer matches what is in the place, the old instance is destroyed and a new one is created, and anything that lived inside it goes with it.
Connect and patch
On connect the server sends a hello, then the whole tree. The plugin reconciles the place against it: creating what is missing, correcting properties, attributes and tags, and sweeping drift out of managed trees.
After that it only receives diffs. A save triggers a rebuild, the new tree is compared to the old one, and just the differences go out:
sync +1 ~1 -0 (25 instances, 3 scripts)
+ ReplicatedStorage/Shared/Inventory [ModuleScript]
~ StarterPlayer/StarterPlayerScripts/Client (Source)
Editing one script sends one property update, not the tree. Patches carry adds, updates and removes, where an update lists exactly which properties, attributes and tags changed.
The file watcher debounces for 120ms, so a burst of writes from a formatter or a branch switch becomes one rebuild. It ignores sourcemap.json, dotfiles, and target, node_modules and .git folders. Editing anything under scripts/ reloads the hook system before rebuilding.
Every file the project reads is tracked, not just source files. Changing a meta.json, an init.meta.json, an .inst.json, or the project file itself all trigger a rebuild. The project file is reloaded first, so editing Include or a service property takes effect straight away without restarting the server:
info default.project.json changed, reloading
sync +0 ~0 -1 (10 instances, 2 scripts)
- ReplicatedStorage/Test
Whichever project file you named when starting the server is the one being watched.
The review
Before the first sync of a connection, the plugin performs a dry run. It walks the whole tree and counts what it would do without changing anything, then shows you the list and waits.
Approve and it applies. Cancel and the connection is dropped. The place is left exactly as it was, rather than half matched.
That review happens once per connection. Live edits afterwards apply immediately, including deletions, so ordinary saves never interrupt you. Reconnecting brings the review back.
Each sync and each patch is wrapped in a ChangeHistoryService recording, so a bad sync is one Ctrl+Z away.
Turn the review off with review changes when connecting in the settings panel if you would rather RoFlux always overwrite freely.
Playtests
RoFlux only runs in Studio's edit mode. Studio re-runs plugins for the playtest DataModel, so the plugin checks RunService:IsEdit() on startup and does nothing at all during a run. There is no syncing into a running game, and no duplicate plugin copies on the server and client.
If a change arrives while a run is in progress it is held rather than applied. When you stop the run, the plugin asks the server for a fresh sync and catches up in one go, so nothing you saved mid-playtest is lost.
Seeing what changed
The server prints each operation as it happens, capped so a large sync cannot flood your terminal.
In Studio, the plugin keeps the same history in its Changes panel. Entries are grouped per sync with a timestamp, colour coded, and filterable:
| Mark | Meaning | Detail shown |
|---|---|---|
+ | created | the class name |
~ | edited | which properties, @attributes and #tags changed |
- | deleted | what it was |
! | failed | why it could not be applied |
Nothing routine is printed to the Studio console. Only real failures show up there, such as a property that does not exist or a class that cannot be created. The panel may well be closed when one happens.
Transport
The plugin connects with HttpService:CreateWebStreamClient using Enum.WebStreamClientType.WebSocket. Messages that arrive before the connection is fully established are buffered and delivered in order, so the opening handshake is never dropped.
If the WebSocket client is unavailable, it falls back to HTTP long polling against the same port. Both paths carry the same messages, so the only difference is latency.
The server also exposes a few plain endpoints, useful for scripting or debugging:
| Endpoint | Returns |
|---|---|
GET /api/info | server version, protocol, project name and id, client count |
GET /api/snapshot | the full tree as it would be sent on connect |
GET /api/poll?cursor=N | messages since N, long polling up to 25 seconds |
GET /roflux | the WebSocket endpoint |
The server binds to 127.0.0.1 only. It is not reachable from another machine.