scenario-roblox-architecture
About scenario-roblox-architecture
An architecture skill for Roblox gameplay programmers and architects. It covers script placement, module boot, server-owned state, and instance lifetimes, alongside a tested arena core and tools for building and auditing it.
Key features
- 01Plan server and client script placement
- 02Boot modules through a two-phase loader
- 03Mirror server state to clients
- 04Bind behavior to tagged instances
- 05Manage connections and object lifetimes
- 06Build and audit an arena core
Use cases
- 01Scaffold architecture for a Roblox project
- 02Organize services and controllers for round-based games
- 03Diagnose module order and dependency issues
- 04Handle respawns, map rotation, and connection cleanup
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ scenario-labs/ skills --skill scenario-roblox-architectureAfter installing, type/scenario-roblox-architecturein Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/critique.mdexpert-notes.mdgui-paths.mdprocedures.mdproject-setup.mdsources.mdscripts/rbx_architecture.pyscripts/agentkit/architecture/ArchAudit.luauBinder.luauLoader.luaulune_arch_audit.luaulune_place_facts.luauscripts/templates/arena/fixtures/FixtureSecondEntry.server.luauFixtureTwiceProbe.client.luauscripts/templates/arena/fixtures/first/FixtureLoadingModule.luauscripts/templates/arena/fixtures/FixtureDoor1/DoorScript.model.jsoninit.meta.jsonPanel.model.jsonscripts/templates/arena/fixtures/FixtureDoor2/DoorScript.model.jsoninit.meta.jsonPanel.model.jsonscripts/templates/arena/fixtures/gui/FixturePerLife.model.jsonscripts/templates/arena/fixtures/gui/FixtureScreens/FixtureNested.model.jsonscripts/templates/arena/fixtures/replicated/CharacterCache.luauCycleA.luauCycleB.luauFixtureRemote.model.jsonGlobalRegistry.luauLeakyClass.luauLeakyServerModule.luauDetails
From the project READMERoblox architecture (gameplay programmer and architect)
Target: Roblox Studio (October 2026 build) on macOS, driven through the scenario-roblox-expert toolkit.
Expert architecture on Roblox means placement and RunContext decide what runs where, one entry
script per side boots every module in two phases, server modules own the truth that attributes
mirror, and tags bind behavior to instances with an owner for every connection. This skill carries
those rules from the sources, corrected by live tests in Studio 0.743, plus a tested arena core
(round loop, teams, maps, HUD, shop, sword) and the tools that build and audit it. If a sibling
skill named here is missing from your available skills, ask the user to install it
(npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and
flag the gap.
REQUIRED BACKGROUND: scenario-roblox-expert (channels, review loop, toolkit, current-Roblox traps).
Status (2026-10-19): 14 live tests pass in Studio 0.743; 8 offline checks pass (18 Lune unit tests, luau-lsp on both type solvers over code, fixtures and snippets, StyLua, selene).
Stance (the expert delta)
-
Pin placement and RunContext: one entry Script per side. Use one Script with RunContext Server in ServerScriptService and one with RunContext Client in ReplicatedStorage (Roblox docs, Script locations; Ludius, n8IDTRW1EfM [00:01:34]; Leif, F3ASTJuO82A [00:03:18]). Everything else is a ModuleScript.
- A Client Script in StarterPlayerScripts ran twice (live), with a "non-legacy RunContext" warning in Output.
- Rojo 7.7.1 turns
.client.luauinto a LocalScript unless the project sets"emitLegacyScripts": false(verified). A LocalScript in ReplicatedStorage never runs.
-
Boot in two phases through one loader. GetDescendants order is undefined, so no module may call another while loading (Suphi Kaner, YsXijBUUmmM [00:04:22]). Init sets up its own state and never yields; Start may yield and call others (Leif [00:02:25]; docs,
start()on every module).Loader.luauadds what the sources skip [added]:- an explicit order, then the rest sorted by path; duplicate names fail (Suphi [00:01:50]);
- one pcall per module, so one broken service does not stop the others;
- an Init that yields is reported as an error;
- a frozen registry and a boot report written as attributes.
-
Server modules own the truth; attributes mirror it; UI owns nothing. A state service never requires its GUI; the GUI calls the service and listens (MrAsync [00:25:46 to 00:31:11]). Publish shared state as attributes on a Configuration that clients observe (Crusherfire, 6Q_p2ObUXSo [00:07:06]). Mirror absolute server times (
EndsAt) so clients count down without a timer remote [added]. A property or attribute change and a remote are not ordered relative to each other (docs, Attributes; Server Authority): put the state in the remote payload, or publish it only as attributes and re-render on each change. -
Bind behavior by tag, filtered by ancestor. One module drives every tagged instance instead of a script per instance (B Ricey, Vi2FckNJv9w [00:02:44]; GnomeCode, UsPYgIW3rv8 [00:01:38]; docs). Two traps the sources miss [added]:
- A tagged template in ServerStorage is in GetTagged too: bind only under chosen ancestors.
- Moving a tagged part from ServerStorage back to Workspace fired no InstanceAdded (live): watch AncestryChanged.
Binder.luaudoes both, connects the signals before GetTagged, and runs the cleanup that setup returns. -
Every connection has an owner with a lifetime. Wire cleanup to the owner that dies with it, for example a per-tycoon Trove instead of the player's (Crusherfire, 0moy04A9vFg [00:16:38]). Add to the cleanup object at creation (B Ricey, 2Msrw5w5cTs [00:09:27]). Set Workspace.PlayerCharacterDestroyBehavior to Enabled (Crusherfire, YWyJHwynM1M [00:12:34]). Under Deferred signals a Destroying handler runs after the destroy (Crusherfire, xz_G2n1Q8-U [00:12:45]). Live, the handler had not run before the yield and then read Parent nil: capture state before removal; removal handlers only clear tables. A listener connected inside another handler misses the fire already queued, and nested fires run breadth-first (Crusherfire [00:03:19 to 00:08:52]; live): connect every listener in Init.
- Screens have a lifetime too: each spawn recopies StarterGui into PlayerGui, except direct children with ResetOnSpawn false; a Folder cancels false (docs, On-screen UI containers). Players die every round, so the HUD and shop use false (GnomeCode, oEWoyT-GBZY [00:14:38]) and their run-once controllers keep valid references (live).
-
Server code and round content never sit in a replicated container. Exploiters can read ReplicatedStorage (Leif [00:01:00]; Crusherfire, 6Q_p2ObUXSo [00:09:13]). Maps stay in ServerStorage until needed, which cuts join traffic (docs, Data model). Clone one into Workspace per round and destroy it after (GnomeCode [00:11:03 to 00:13:51], who keeps them in ReplicatedStorage). Crusherfire relocates server modules from a system folder to ServerScriptService at start (6Q_p2ObUXSo [00:09:47]). Require siblings by relative path so a system can move (Crusherfire [00:23:46]).
-
The client asks; remotes are made in code. Never create remotes by hand in Explorer (Leif, F3ASTJuO82A [00:04:28]). The client asks "can I build here?" and the server decides (Leif [00:05:39]). Check on the client to save bandwidth and again on the server (MrAsync [00:16:37]).
-
Pick the framework by team, not taste. Knit-style frameworks decide for you, and everything depends on one service (B Ricey, MyK4o2pIpQw [00:17:58]); the 2025 sources build small loaders (Ludius, Leif, Crusherfire). Solo or small team: this loader, Signal and a cleanup module; an existing Knit codebase: keep it and add the audit [added]. A class only for several stateful instances (B Ricey [00:07:10]); a service is a singleton table (Leif [00:02:00]).
Establish first
- Session shape: round-based, persistent world or hub; player count; teams.
- New place or existing code: Rojo or Script Sync repository, Studio-only, or an existing framework to keep.
- Team size and whether systems are shared across places (Roblox Packages for your own systems, Wally for third-party code).
- Server Authority (
AuthorityMode = Server): it forces Deferred signals and replicates only the first 64 attributes per instance. - StreamingEnabled, on in new places (these tests ran with it off): clients wait for streamed parts.
Defaults: Rojo project with emitLegacyScripts false, the arena skeleton below, Deferred signals,
PlayerCharacterDestroyBehavior Enabled, --!strict.
Procedure
Setup: import rbx_architecture as A (it puts the lead's scripts on sys.path). Full code and
recorded results are in references/procedures.md.
-
Scaffold.
A.scaffold(dest, name, fixtures=False)copies the arena skeleton plus Loader and Binder, writes the Rojo project and builds the place throughrbx_env.new_place. GATE:A.place_facts(place)shows Server and Client RunContexts, SignalBehavior Deferred and PlayerCharacterDestroyBehavior Enabled;A.check(dest)returns 0 errors on both type solvers. -
Map the modules. Write the module map before code. Dependencies point from services to shared modules, never from a service to UI or from replicated code to the server.
Module Side Owns PlayerService server per-player and per-character tables, cleared on leave TeamService server team assignment and alive teams RoundService server the RoundMachine state, StateChanged, the GameState mirrorWeaponService server swords by tag: give, take, re-give on respawn MapService server one map clone per round, rotation, pads, hazards by tag DamageService, ShopService server handed to scenario-roblox-gameplay and scenario-roblox-data RoundMachine, Config, Signal shared pure logic, constants, signals (run in Lune) RoundController, ShopController client HUD and shop, wired once, ResetOnSpawn false GATE:
A.audit_file(place)reports 0 errors and 0 warnings, no require cycle and 0 unresolved requires. -
Boot. Each entry script calls
Loader.boot(Loader.ordered(folder, order), {publish = script}). GATE: in a playtest,A.boot_report(pt)shows BootOk true, BootErrors 0 and BootCount 1 on both sides, with everyinit:before everystart:. The console has no "required recursively" and no[Loader]line. -
State and mirrors. RoundService writes
EndsAt,Winner, thenRoundState; controllers re-render on every attribute change.Test_*attributes on GameState shorten the loop in Studio only. GATE: server state, client mirror and HUD text agree; capture the HUD, runimage_checksand open the image. -
Tags and lifetimes. Bind tagged instances with
Binder.bind(tag, setup, {ancestors = ...}). Clear player tables on PlayerRemoving and character tables on CharacterRemoving. Re-give round items on CharacterAdded, because a respawn empties the Backpack. MapService clones the map at Intermission and destroys it on the way to Lobby. GATE: five respawns mid-round leave one tracked character and one bound sword on the map. After a respawn, the HUD and shop are the same instances. In Lobby, Workspace holds 0 maps and 0 bound hazards, the instance count is back within 10, and frames meet the gate across a map load. -
Observe and iterate live. Open the place once with
rbx_env.open_placeinsiderbx_env.place_slot(). After each edit, push sources withA.sync_sources(studio, dest)instead of reopening. GATE:A.run(s, "return ArchAudit.all(game)", kit=["ArchAudit"])matches the offline tally, andtests/code/scenario-roblox-architecture/run_live.pyexits 0.
Numbers
| Value | Number | Relative to |
|---|---|---|
| Player latency | 100 to 300 ms | docs, Client-server runtime |
| Studio playtest latency | 0 ms; simulate 50 to 150 ms each way | docs; Studio Settings, Network, or settings().Network |
| Module size smell | 500+ lines | MrAsync [00:20:54]; ArchAudit module-size |
| Replicated attributes under Server Authority | first 64 per instance; names and strings up to 50 characters | docs, Server Authority |
| Boot, measured | server 5 services 1.56 to 2.52 ms; client 2 controllers 0.40 to 1.08 ms | live, Studio 0.743 |
| Client order, measured | ReplicatedFirst ran 35 to 36 ms before client Main; IsLoaded false, then true; no character yet | live |
| Five respawns mid-round | instances +3 or -3 (about 46,150); 1 character, 1 bound sword | live, 6 runs |
| Arena defaults | 2 players, 15 s intermission, 120 s round, 5 s ending, 0.25 s tick | [added] |
| Frame gate | p95 at most 20 ms, worst at most 33.3 ms at 60 fps (1.2 x and 2 x the budget) | scenario-roblox-expert |
| Map cycle, measured | Server p95 17.3 ms, worst 17.5 to 20.0 ms across a clone; Lobby instance delta 0 | live, 3 runs |
Quality gates
- Measurable:
- ArchAudit: 0 errors and 0 warnings outside fixtures; AgentKit
Audit.all(game)has 0 errors. - Boot report on both sides: BootOk, BootErrors 0, BootCount 1.
- Console: no "required recursively", "not a valid member",
[Loader]or "non-legacy RunContext" lines. - Lifetimes:
Charactersequals the player count after respawns; in Lobby, 0 maps and 0 bound hazards, and the instance count is back within 10; Server and Client frames meet the gate. run_live.pyexits 0; the Lune unit tests pass; luau-lsp has 0 errors on both solvers (A.check); StyLua and selene are clean.
- ArchAudit: 0 errors and 0 warnings outside fixtures; AgentKit
- Visual: a capture during a round shows the HUD with the mirrored state;
image_checksraises no flag and you open the image. A human reviewer gets an Explorer capture.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Client Script in StarterPlayerScripts | runs twice; Studio warns in Output | move the entry Script to ReplicatedStorage, or use a LocalScript in StarterPlayerScripts |
Rojo default emitLegacyScripts | client entry in ReplicatedStorage never runs | set "emitLegacyScripts": false |
require of a live service from execute_luau | fresh copy: fields nil, state empty | read the module's Studio-only attributes |
| Tagged template in ServerStorage | template gets behavior | ancestors filter |
| Reparent within the DataModel | behavior missing after moving back | AncestryChanged watch (Binder) |
| Round items given once | sword gone after respawn | re-give on CharacterAdded during the round |
| Reading Parent in Destroying or CharacterRemoving | nil under Deferred | capture state before destroy |
| Listener connected inside a handler | misses the fire already queued | connect in Init, before anything fires |
SetAttribute, then FireAllClients; the handler reads the attribute | can read the old value on live servers | send the state in the payload |
| Maps in ReplicatedStorage or Workspace | every client downloads every map at join | ServerStorage, one clone per round (maps-replicated) |
| Run-once controller holding a ScreenGui with ResetOnSpawn true | HUD gone after the first death | ResetOnSpawn false |
| ResetOnSpawn false on a ScreenGui inside a Folder | rebuilt at every respawn anyway | direct child of StarterGui (reset-ignored-nested) |
_G registry, duplicate module names | silent overwrite | Loader registry; ordered errors on duplicates |
| Package with AutoUpdate off | copies never update | AutoUpdate on (Roblox Learn, AzKZy2BqIh8 [00:09:55]) |
Handoffs
- Receives from: scenario-roblox-expert (brief, toolkit, place slot); scenario-3d and scenario-game-assets (models that arrive as tagged templates in ServerStorage).
- Delivers to: scenario-roblox-networking (service layout, remote module slot), scenario-roblox-data (DataService boot slot, PlayerRemoving hooks), scenario-roblox-gameplay (hits and damage inside Binder setups), scenario-roblox-ui (controllers on GameState, the shop screen), scenario-roblox-luau (types, Signal, deferred events), scenario-roblox-tooling (Rojo project, CI), scenario-roblox-performance (leaks, streaming), scenario-roblox-animation (character scripts).
- Form: the Rojo
src/tree, the module map, the ArchAudit report andresults.json.
Current Roblox notes
- Rojo 7.7.1 drops
"Position"on a Part with exit 0 and leaves it at the origin: write a 12-number"CFrame"(verified). - Rojo 7.7.1 builds Script Sync's
.local.luauand.legacy.luauas ModuleScripts namedX.localandX.legacy(verified): do not mix the two suffix schemes. - require-by-string:
./,../,@selfand@game; special handling ofInitmodules is gone (version deltas row 23). - SignalBehavior: new templates and Server Authority use Deferred;
Defaultstill maps to Immediate in older places.workspace.SignalBehaviorfailed with "not a valid member" fromexecute_luau(live): read it in Properties or from the file with Lune. - PlayerCharacterDestroyBehavior is NotScriptable: set it in Properties or in the Rojo project.
CollectionService:CreateCollectionis documented, but in Studio 0.743 it errored "Collections are not enabled" (live). Use tags and Binder.- Next Gen Studio UI is the default: menu paths in
references/gui-paths.md. - Creator Store assets cannot
require(assetId): vendor the code.
References
references/expert-notes.md: principles by expert with timestamps, and live corrections.references/procedures.md: every procedure with full code, its test and the recorded result.references/critique.md: the self-review rubric.references/project-setup.md: client start, StreamingEnabled, Packages and team tools, two-client tests.references/gui-paths.md: Studio menus and panels for the same steps.references/sources.md: every source with URL and best timestamps.scripts/rbx_architecture.py: scaffold, audit, place facts, sync, boot report.scripts/agentkit/architecture/: Loader, Binder, ArchAudit and the Lune runners.scripts/templates/arena/: the tested arena core and its audit fixtures.