Roblox pidgeonholes LocalScripts into _G -like hierarchy

User story

As a developer, I currently have to structure my clientside game logic in awkward ways because LocalScripts can only run in select locations (backpack, character, etc). For instance, if I want to hook up a shop so it opens when players stand on a kiosk, I either have to:

Handle with Server Scripts:

I can use a script parented to the kiosk to check for when players are standing on it and ask them to pull up the shop UI. This is awful though, because the server doesn’t have enough information. There are clientside preconditions to opening the shop such as the shop not already being open, another fullscreen interface not being open, the user not being in a cutscene, etc. It’s awful when 99% of my shop logic is clientside, but the trigger to open it is server side.

Use StarterPlayerScripts:

Handling this through a LocalScript in StarterPlayerScripts is not ideal either. What if I decide to change the parent/name or remove one of the kiosks in my game? Now I have to go hunt down wherever I hooked into the kiosk in this massive amalgamation of code I’ve shoved into the Instance form of _G.

Ideal

I want to be able to decouple my game. Shared modules/services (e.g. keybinds) used by multiple components of the game would remain in global locations, but individual components would have their own logic localized. If I have a shop kiosk, it should have all its scripts within its own model so I don’t have to manually hunt down and update everything that uses it in Starter_GScripts every time I make a change.

There is also room for improvement with LocalScript <-> Script communication. Some of the endorsed models with Humanoids come together pretty nicely because they can run both scripts and localscripts within the model in a closed loop. They don’t need to pollute Starter_GScripts, ship models with instances named PutMeInStarterPlayerScripts, or require micromanagement of distant scripts every time a model is added/removed/has its parent changed. All of the logic is contained to where it’s needed.

Implementation

At one point, LocalScripts needed to be parented to these specific locations. Pre-FilteringEnabled, if LocalScripts could be run anywhere, there would have been no distinguishable difference between them and server scripts. With FilteringEnabled though, now that’s not an issue – LocalScripts and Scripts are two distinct entities. With that out of the way, there should be a way to run client code from anywhere in Workspace, similarly to what we can do with Scripts.

We can’t just switch the behavior of LocalScripts so they run anywhere, as there are bound to be large amounts of games that parent them somewhere under Workspace expecting that they won’t run. Games are not forced to use FE either, even though it’s strongly encouraged. We would need to maintain backwards compatibility and ensure LocalScripts made in one game still work in another (e.g. property/new class). It’s hard to propose good implementation for this, so I’m not going to ask for any specific behavior – the engineers will be able to make a better judgement call.

12 Likes

Can’t you just use collection service to tag them through a server script or offline and just retrieve the tags from a local script? (I’ve never used collectionservice because I’ve never needed it but it might be especially useful in this scenario)

Is a player’s PlayerScripts folder also awkward to put LocalScripts in?

You should have a LocalScript do almost-constant checks for nearby kiosks to trigger a client-side flag to prepare the shop UI, and have the server double-check if the player is indeed by the kiosk when prompted to open it. This way the client-side scripts can check the preconditions and, presumably, you won’t have to put a script in every single kiosk for this task.

Can you describe your shop logic? The client should only be responsible for handling the interface navigation and purchase prompting; the server should be responsible for relaying shop data, the player’s information, processing transactions, etcetera.

Sounds like this is more of an organization issue than a script engineering obstacle.

1 Like

That only helps with name/parent changes of the top-level model. If I change any of the components (e.g. hierarchy/name), the issue persists. Developers still also need to shove their entire clientside codebase into StarterPlayerScripts.

1 Like

Yes, it’s the feature request’s namesake. I have > 5,000 lines of code shoved into StarterPlayerScripts.

Yes, I was listing an example of what I should not have to do.

Logic in question is bringing up the shop and interface control. Contents of the shop are handled through a server script and do not affect the clientside implementation.

Yes, the whole feature request is concerning the parent restrictions on LocalScripts that prevent developers from organizing their game in a scalable way.

1 Like

I don’t think you understand what I meant, and frankly I don’t understand your counter arguments.

  1. What does the total amount of lines of code you have in PlayerScripts have to do with your issue of running LocalScripts from there? Are you concerned about memory?
  2. Sorry, but I really can’t see why you feel like you “shouldn’t have to” use a widely-used position checking technique. That just isn’t a problem caused by scripts.
  3. That sounds like proper shop behavior. The shop should be ultimately controlled client-side and the important tasks should only be carried out by the server. The client should also be in control of opening and closing the shop, but if necessary, quickly double-checked by the server. It’s more work for the game to have to operate the entire shop server-side.
  4. No, I meant an organization issue with your game. If you’re can easily break your game logic by changing the parent or name of a kiosk and you have difficulty making the appropriate script changes, your code isn’t structured as best as it can be. Make sure your code has a reference point that you can easily change, like a local variable for the names of the kiosks.

As you said in your closing statement, it would be difficult to consider backwards compatibility, and I truly believe the issues you presented are either fixable without changing LocalScript run behavior or are personal concerns about your own organization methods.

Module scripts can be parented anywhere (although they shouldn’t be if you’re going to be having a solid framework)

I think @dragonfrosting is 100% right in that it is an issue with your organization and not a reason localscripts should be allowed to run anywhere

1 Like

I want to show the way I handle this in my game so that you can elaborate on why this pattern doesn’t work in your case.

I have this module script:

local CollectionService = game:GetService("CollectionService")

local Binder = {}
Binder.__index = Binder

function Binder.new(tagName, class)
	local self = Binder.newDisabled(tagName, class)

	self:enable()

	return self
end

function Binder.newDisabled(tagName, class)
	local self = {
		tagName = tagName,
		class = class,
		instances = {},
	}
	setmetatable(self, Binder)

	return self
end

function Binder:destroy()
	self:disable()
end

function Binder:enable()
	if self.enabled then return end
	self.enabled = true

	for _,inst in pairs(CollectionService:GetTagged(self.tagName)) do
		self:_add(inst)
	end
	self.instanceAddedConn = CollectionService:GetInstanceAddedSignal(self.tagName):Connect(function(inst)
		self:_add(inst)
	end)
	self.instanceRemovedConn = CollectionService:GetInstanceRemovedSignal(self.tagName):Connect(function(inst)
		self:_remove(inst)
	end)
end

function Binder:disable()
	if not self.enabled then return end
	self.enabled = false

	self.instanceAddedConn:Disconnect()
	self.instanceRemovedConn:Disconnect()

	for _,obj in pairs(self.instances) do
		obj:destroy()
	end
	self.instances = {}
end

function Binder:_add(inst)
	if typeof(self.class) == 'function' then
		self.instances[inst] = self.class(inst, self)
	else
		self.instances[inst] = self.class.new(inst, self)
	end
end

function Binder:_remove(inst)
	self.instances[inst]:destroy()
end

function Binder:get(inst)
	return self.instances[inst]
end

return Binder

The idea is that you give it a CollectionService tag name and a class (i.e. a table with with a .new() function which returns a table with a :destroy() function), and it will automatically create a 1:1 mapping between Instances and this class. I use it both on the client and the server.

In order to implement objects like bombs or vehicles, I create a class for them in the code, and then use this Binder class to attach them to in-world objects. No hardcoded hierarchy paths needed. I can add, remove, and duplicate these objects freely. Using dependency injection I can avoid needing to use global variables to communicate between components.

In order to implement the kiosk I would specifically add a modulescript on the client containing this:

local Kiosk = {}
Kiosk.__index = Kiosk

function Kiosk.new(model, binder)
    local self = {
        model = model,
        binder = binder,
    }
    setmetatable(self, Kiosk)

    self.pad = self.model:FindFirstChild("Pad")
    if not self.pad then
        -- error or something, I generally assume my tagged objects have well formed hierarchy
    end
    self.touchedConn = self.pad.Touched:Connect(function(instance)
        self:touched(instance)
    end)

    return self
end

function Kiosk:touchedBy(instance)
    local character = instance:FindFirstAncestorOfClass("Model")
    if character == game.Players.LocalPlayer.Character then
        -- N.B. this part
        -- when this method gets called, we have all of the context information necessary to create a GUI
        self.binder.guiManagerOrSomething:openKioskGui(self)
    end
end

function Kiosk:destroy()
    -- Quenty's maid pattern works excellently here
    self.touchedConn:Disconnect()
end

return Kiosk

In order to instantiate this, somewhere in my main module script I would do something like this:

local Binder = require(script.Parent.Binder)
local Kiosk = require(script.Parent.Kiosk)

local kioskBinder = Binder.newDisabled("Kiosk", Kiosk)
-- this is the dependency injection part
kioskBinder.guiManagerOrSomething = guiManagerOrSomething -- a gui manager thing created somewhere in this top level script
kioskBinder:enable()
3 Likes

I mean, I could use that, but I’m just trading dependency on random LocalScripts in StaterPlayerScripts for dependency on some really specific and idiosyncratic god system – it’s still not self-contained.

Scenario 1

Goal: Reuse kiosk in another game I make soon after

Step 1) Redo the model so it fits with the game’s aesthetic
Step 2) Change the UI so it fits with the game’s aesthetic
Step 3) Add a RemoteFunction endpoint in new game that returns list of for-sale items in the correct format
Step 4) Paste kiosk into new game <- Process complete if model self-contained.
Step 5) Have to use binder class in game
Step 6) Have to use class system in game
Step 7) Add Kiosk tag to main module <- Process complete for binder/etc system

Scenario 2

Goal: Reuse kiosk in a collaborative game

Step 1) Redo the model so it fits with the game’s aesthetic
Step 2) Change the UI so it fits with the game’s aesthetic
Step 3) Add a RemoteFunction endpoint in new game that returns list of for-sale items in the correct format
Step 4) Paste kiosk into new game <- Process complete if model self-contained.
Step 5) Ask team if we can switch game logic to binder class <- If team says no, can’t reuse kiosk
Step 6) Teach team how binder class works and get them to start using it for the entire game so we don’t have a hodgepodge mess of a backendI’;
Step 7) Update binder class so it follows whatever class implementation we’re using (e.g. require(replicatedStorage.standardLib).object(“objName”)
Step 8) Add Kiosk tag to main module <- Process complete for binder/etc system

Scenario 3

Goal: Provide kiosk for another developer to use

Step 1) Ensure documentation for model is up to par
Step 2) Release kiosk as model <- Process complete if model self-contained.
Step 3) “Oh BTW you have to drop what you were using previously and adopt my whole game system for your project”
Step 5) Developer has to use binder in their game
Step 6) Developer has to learn how to use binder
Step 7) Developer has to use class system in game
Step 8) Developer has to learn how to use class system if they didn’t already use one
Step 9) Add Kiosk tag to main module <- Process complete for binder/etc system


Developers love their really specific frameworks, but these frameworks are a nail in the coffin for collaboration and coupling. At some point, the developer’s game content is so dependent on that structure that it’s impossible to reuse that content without copying over the entire game framework. Anyone that works with you on a game has to learn how to use it, and you might very well spend more time ironing out issues because they were trying to get their code to work and learn the framework than actually working on the game.

2 Likes

I understand the problem better now. This happens a lot outside Roblox, it’s pretty much the basis of reinventing the wheel.

The best solution we have today seems to be putting everything into module scripts and give the developer the option of replacing the “glue code” with their own (while still including defaults). That “glue code” can be as much as half the size of the library though, so it’s not an ideal solution.

I think having fully self contained models doesn’t work. Having the code physically inside of the kiosk model means you’re copying and pasting the code, which makes it impossible to edit. Even when using linked scripts, if you ever need to add a new module to the code in the model, you have to update every copy of the model. Maybe if we had a concept of a “linked folder” or something which links an entire hierarchy instead of just the contents of the Source property.

I don’t think we should just let LocalScripts execute anywhere.

I’ve lately been thinking a “Component” instance would be cool. It’d have a property that holds a script instance reference. When you assign it to a Script or LocalScript, it’d execute the script as though you’d copied and pasted it into the model. It’d basically work a lot like my Binder class, but built into Roblox in a way that isn’t specific to my weird framework. It’d only run when you put it somewhere that Scripts normally run (it will execute a LocalScript if it’s in the workspace). It’d probably delay running LocalScripts until the PlayerGui spawns like normal scripts in StarterPlayerScripts.

The script property would still point to the original script, so module script references work. There’d probably have to be a new component global or something so that you can access the model the component is parented to. This approach has the following advantages:

  • It’s compatible with the way developers write games today.
  • It allows you to put your source code anywhere you want.
  • You’re not pushed towards a class system, you can still write procedural-style scripts.
  • It’s easy to migrate from copy/paste and linked source code, including for random assets inserted from the toolbox.
  • Because it’s built in, your code has no dependencies on a framework.
  • Probably easy to understand?
  • Doesn’t require uploading assets to the website like linked source.
  • Can’t fail like linked source and InsertService can.
  • Doesn’t require third party tooling to manage CollectionService. It’s a simple object that can be manipulated from the explorer and properties panes.
  • If a single object errors, it doesn’t crash your entire game (which the single-script approach does unless you add pcall/spawn/BindableFunction barriers).

It has these downsides:

  • Has the same cross-Script context barrier that games using more than one script object have. Where you need to use either BindableFunctions or _G/shared to communicate across it.
  • More API surface.
  • Potentially confusing that scripts are being executed.
  • How to package this into models for the toolbox? Should it automatically clone the referenced script as a child of the component, and then “unpack” it into ReplicatedStorage when inserted from studio?

These are just my thoughts, they’re not necessarily representative of Roblox.

2 Likes