|
|
||
|---|---|---|
| .gitignore | ||
| LICENSE | ||
| README.md | ||
Chemical - Reactive State & UI Framework for Roblox Luau
Version: 0.1.0b ALPHA (as of 6/3/2025, per comments) Author: Sovereignty (Discord: sov_dev)
Table of Contents
- Introduction
- Core Philosophy
- Installation & Setup
- Core Reactive Primitives
- UI Creation & Management
- State Replication (
Chemical.Reaction) - Client-Side Routing (
Chemical.Router) - Utility Functions
- Under The Hood (Advanced)
- Type System
- Examples
1. Introduction
Chemical is a comprehensive Luau framework for Roblox game development, emphasizing a reactive programming paradigm. It aims to simplify state management, UI development, and server-client data synchronization by providing a suite of interconnected tools. With Chemical, developers can build dynamic and responsive user interfaces and game systems that automatically react to changes in their underlying data.
Key features include observable values, derived (computed) values, an ECS-backed architecture, declarative UI construction, client-side routing, and a powerful state replication system.
2. Core Philosophy
- Reactivity: State changes should automatically propagate through the application, updating dependent values and UI elements without manual intervention.
- Declarative UI: Describe what your UI should look like based on the current state, and let Chemical handle the updates.
- Centralized State (Optional): While not strictly enforcing a single global store,
Chemical.Reactionfacilitates managing and syncing shared game state. - Performance: Leveraging an ECS backend and a custom packet system for efficient data handling and networking.
- Developer Experience: Providing clear, typed APIs (using Luau type annotations) and utilities to streamline common tasks.
3. Installation & Setup
- Place the
ChemicalrootModuleScript(and its descendant file structure from the.rbxm) into a suitable location, typicallyReplicatedStorageto be accessible by both server and client. - Ensure the
UseReactionsBoolValue inChemical/Configurationis set totrueif you intend to use theChemical.Reactionstate replication system. Iffalse, attempting to useReactionwill result in a warning/error.
-- Accessing the Chemical library
local Chemical = require(game.ReplicatedStorage.Chemical)
4. Core Reactive Primitives
These are the fundamental building blocks for creating reactive data flows.
Value<T>
The Value<T> object is the most basic reactive unit. It encapsulates a single piece of data that can be read and written. When its data changes, any Computed values or Observers depending on it are notified.
API:
Chemical.Value(initialValue: T): Value<T>: Constructor.:get(): T: Retrieves the current value. If called within aComputedfunction orWatchtarget getter, it registers thisValueas a dependency.:set(newValue: T): Sets a new value. If the new value is different from the old, it triggers updates to dependents.:increment(amount: number?): For numericValues. Increments the value byamount(defaults to 1).:toggle(): For booleanValues. Flips the boolean state.:key(key: any, newValue: any): For tableValues. Setstbl[key] = newValueand triggers updates.:insert(itemValue: any): For array-like tableValues. Equivalent totable.insert(tbl, itemValue).:remove(itemValue: any): For array-like tableValues. Removes the first occurrence ofitemValue.:destroy(): Destroys theValueobject, cleaning up its ECS entity and notifying dependentComputeds orObservers (which may also destroy themselves).
Example:
local Chemical = require(path.to.Chemical)
local playerScore = Chemical.Value(0)
local isGameOver = Chemical.Value(false)
print(playerScore:get()) -- Output: 0
playerScore:increment(10)
print(playerScore:get()) -- Output: 10
isGameOver:set(true)
Computed<T>
A Computed<T> object represents a value that is derived from one or more other reactive objects (Values or other Computeds). It automatically re-evaluates its derivation function whenever any of its dependencies change, if the new value differs from the old it will appropriately cache the value and respond.
API:
Chemical.Computed(derivationFunction: () -> T, cleanupFunction?: (oldDerivedValue: T) -> ()): Computed<T>: Constructor.derivationFunction: A function that returns the computed value. AnyValue:get()orComputed:get()calls inside this function establish dependencies.cleanupFunction(optional): A function called with the previous computed value right before theComputedis re-cached due to a dependency change, or when theComputedobject is destroyed. Useful for cleaning up side effects or resources tied to the old value.
:get(): T: Retrieves the current computed value. If called within anotherComputedorObserver, it registers thisComputedas a dependency.:destroy(): Destroys theComputedobject, cleaning up its value ifcleanupwas provided as well as its ECS entity and notifying dependentComputeds orObservers (which may also destroy themselves).
Example:
local Chemical = require(path.to.Chemical)
local firstName = Chemical.Value("Jane")
local lastName = Chemical.Value("Doe")
local isGameOver = Chemical.Value(true)
local fullName = Chemical.Computed(function()
return firstName:get() .. " " .. lastName:get()
end)
print(fullName:get()) -- Output: Jane Doe
firstName:set("John")
task.wait() --Computeds will run on the next frame after the change.
print(fullName:get()) -- Output: John Doe (automatically updated)
local characterDescription = Chemical.Computed(function()
local name = fullName:get() -- Dependency on another Computed
local status = isGameOver:get()
return string.format("%s (Game Over: %s)", name, tostring(status))
end, function(oldDescription) --Optional Cleanup method
print("CharacterDescription cleanup. Old value:", oldDescription)
end)
print(characterDescription:get()) -- Output: John Doe (Game Over: true)
isGameOver:set(false) -- Triggers re-computation of characterDescription
task.wait()
print(characterDescription:get()) -- Output: John Doe (Game Over: false)
isGameOver:set(false) -- Because isGameOver is already == false, it will not triggers re-computation of characterDescription
-- Nor will it cause any observational changes/events to be triggered as the value of isGameOver did not change.
-- This applies to computeds as well.
Observer
An Observer allows you to react to changes in a Value or Computed object by executing a callback function.
API:
Chemical.Observer(target: Value<any> | Computed<any>): Observer: Constructor.:onChange(callback: (newValue: any?, oldValue: any?) -> ()): () -> (): Registers a callback function to be invoked when the observedtarget's value changes.- Returns a
disconnectFunctionthat, when called, unregisters this specific callback.
- Returns a
:destroy(): Destroys theObserverand disconnects all its listeners.
Example:
local Chemical = require(path.to.Chemical)
local health = Chemical.Value(100)
local healthObserver = Chemical.Observer(health)
local disconnectHealthListener = healthObserver:onChange(function(newHealth, oldHealth)
print(string.format("Health changed from %s to %s", tostring(oldHealth), tostring(newHealth)))
end)
health:set(80) -- Output: Health changed from 100 to 80
health:set(80) -- No output (value didn't change)
health:set(95) -- Output: Health changed from 80 to 95
disconnectHealthListener() -- Stop listening for this specific callback
health:set(100) -- No output from the disconnected listener
healthObserver:destroy() -- Destroy the observer entirely
Element
An Element is a specialized reactive state value, primarily designed for managing the visibility or active state of UI components, in conjunction with the Chemical.Router.
The different between Element and Value is that Elements have reactive parameters which can be retrieved and are set by the Router.
API:
Chemical.Element(): Element: Constructor. Initializes with a state offalse.:get(): boolean: Gets the current boolean state. Registers a dependency if used in aComputed.:set(newState: boolean): Sets the boolean state.:params(): { from?: string, [any]: any }: This is the reactive parameter object, which can contain the reserved keyfrom.:params(newParams: { from?: string, [any]: any }): Sets or gets an associated parameters table. TheRouteruses this to pass information like the previous path (from) when an element's state changes due to a route transition.:onChange(callback: (newState: boolean, fromPath?: string) -> ()): () -> (): Listens for changes to the element's boolean state. The callback receives the new state and thefromproperty of the current params. Returns a disconnect function.:destroy(): Destroys theElement..__persistent: boolean?: (Internal property set by Router) If true, the Router will not automatically set this Element tofalsewhen navigating away from its associated path.
Example (Conceptual, often used with Router):
local Chemical = require(path.to.Chemical)
local router = Chemical.Router
local settingsPageElement = Chemical.Element()
-- In UI Creation:
-- Visible = settingsPageElement,
router:paths({
{Path = "/settings", Element = settingsPageElement}
})
-- Elsewhere (e.g., Router logic):
-- router:to("/settings", { message = "hello" })
Watch
Watch allows you to observe a specific key within a table that is itself held by a Value or Computed object. The callback triggers only when the value associated with that specific key changes.
API:
Chemical.Watch(targetGetter: () -> ({ targetTableContainer: Value<{[any]:any}> | Computed<{[any]:any}>, key: any }), callback: (newValueForKey: any?, oldValueForKey: any?) -> ()): () -> (): Constructor.targetGetter: A function that must return a table and a string:- The
ValueorComputedobject that holds the table you want to watch. - The specific
keywithin that table whose value changes you want to monitor. - Any reactive
:get()calls within will establish dependencies for re-evaluating which table/key to watch if those dependencies change (though the primary use is for a single reactive table). callback: A function invoked when the value oftargetTableContainer:get()[key]changes. It receives the new and old values for that specific key.- Returns a
disconnectFunction.
- The
Example:
local Chemical = require(path.to.Chemical)
local userProfile = Chemical.Value({
username = "User123",
score = 100,
inventory = {"sword", "shield"}
})
local disconnectUsernameWatch = Chemical.Watch(
function() return userProfile:get(), "username" end,
function(newUsername, oldUsername)
print(string.format("Username changed from '%s' to '%s'", oldUsername, newUsername))
end
)
local disconnectScoreWatch = Chemical.Watch(
function() return userProfile:get(), "score" end,
function(newScore, oldScore)
print(string.format("Score changed from %s to %s", oldScore, newScore))
end
)
-- Update the whole profile table
userProfile:set({
username = "PlayerOne",
score = 150,
inventory = {"sword", "shield", "potion"}
})
-- Output:
-- Username changed from 'User123' to 'PlayerOne'
-- Score changed from 100 to 150
-- Update using :key (also triggers Watch if the specified key is watched)
userProfile:key("score", 200)
-- Output:
-- Score changed from 150 to 200
Watch has a very specific use case in regards to Elements.
local Chemical = require(path.to.Chemical)
local router = Chemical.Router
local someElement = Chemical.Element()
router:paths({
{ Path = "/settings", Element = someElement }
})
local disconnect = Chemical.Watch(
function() return someElement:params(), "message" end,
function(new, old) print("New message: ", new) end
)
router:to("/settings", { message = "hello" }) --Prints "New message: hello"
--Element:params() can also be accessed directly, usually inside of an Observer after the Element's state has changed.
5. UI Creation & Management
Chemical provides a declarative API for creating and managing Roblox GUI elements, making it easy to bind UI properties to reactive state.
Chemical.Create()
Chemical.Create(className: string): (propertyTable: dictionary): GuiObject
Creates a new instance of the specified className (e.g., "Frame", "TextLabel") and applies properties defined in propertyTable.
This supports intellisense for each className of GuiObjects!
propertyTable Keys and Values:
- Standard Properties: (e.g.,
Size,Position,BackgroundColor3,Text,Visible)- Can be static values:
Size = UDim2.fromScale(0.1, 0.1) - Can be a
ValueorComputedobject:Visible = myVisibilityValue(wheremyVisibilityValueis aValue<boolean>). The UI property will automatically update when the reactive object changes.
- Can be static values:
Parent: Instance | Value<Instance> | Computed<Instance>: Sets the parent of the created element. Can be static or reactive.Children: {GuiObject}: An array of otherChemical.Create()calls or static GuiObject references. The created child elements will be parented to this element.- UI Traits (see below).
Example (from LocalScript Examples):
local Chemical = require(path.to.Chemical)
local Create = Chemical.Create
local Value = Chemical.Value
local PlayerGui = game.Players.LocalPlayer.PlayerGui
local frameColor = Value(Color3.fromRGB(200, 200, 200))
local childFrameRef = Value() -- Will hold the reference to the child frame
local mainFrame = Create("Frame"){
Size = UDim2.fromScale(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundColor3 = frameColor, -- Reactive property
Visible = true,
Children = {
Create("Frame"){
Name = "ChildInnerFrame",
Size = UDim2.fromScale(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundColor3 = Color3.fromRGB(100, 100, 100),
Visible = true,
[Chemical.Ref] = childFrameRef -- Assign instance to childFrameRef Value
},
Create("TextButton"){
Name = "ColorChangeButton",
Size = UDim2.fromOffset(100, 30),
Position = UDim2.fromScale(0.5, 0.8),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = "Change Color",
[Chemical.onEvent("MouseButton1Click")] = function()
frameColor:set(Color3.fromRGB(math.random(0, 255), math.random(0, 255), math.random(0, 255)))
end
}
},
Parent = Create("ScreenGui"){
Name = "MyChemicalScreenGui",
Parent = PlayerGui
}
}
task.wait(2)
print("Child frame reference:", childFrameRef:get().Name) -- Output: ChildInnerFrame
Chemical.Give()
Chemical.Give(instance: GuiObject): (propertyTable: dictionary): GuiObject
Applies reactive properties and traits to an existing instance. The propertyTable structure and capabilities are the same as for Chemical.Create(). This is useful for hydrating GUIs not created by Chemical or for applying reactive behavior incrementally.
local existingFrame = script.Parent.SomeFrame -- Assuming a Frame exists
local frameVisible = Chemical.Value(true)
Chemical.Give(existingFrame) {
Visible = frameVisible, --Reactive object value
[Chemical.onChange("AbsoluteSize")] = function(newSize)
print("Frame AbsoluteSize changed to:", newSize)
end
}
task.wait(3)
frameVisible:set(false) -- existingFrame will become invisible
UI Traits
Special keys used within the propertyTable of Create and Give to add specific behaviors:
[Chemical.Ref] = refValue: Value<Instance>: When the UI element is created (byCreate), or hydrated (byGive),refValue:set(createdInstance)is called. This allows you to get a reactive reference to the instance itself.[Chemical.onEvent(eventName: string)] = callback: (() -> ()) | Value<boolean>: Connects to the specifiedeventName(e.g., "MouseButton1Click", "MouseEnter") of the UI element.- If
callbackis a function, it's called when the event fires. Arguements of the event are passed to the function. - If
callbackis aValue<boolean>, its value is set totruewhen the event fires. (The actual arguments of the event are not passed to the Value's set method).
- If
[Chemical.onChange(propertyName: string | Value<any> | Computed<any>)] = callback: ((newValue: any) -> ()) | Value<any>:- If
propertyNameis a string: Listens toInstance:GetPropertyChangedSignal(propertyName).- If
callbackis a function, it's called with the new property value.- If
callbackis aValue, itssetmethod is called with the new property value.
- If
- If
propertyNameis aValue,Computed, orElement(reactive object): Creates anObserverfor this reactive object.- If
callbackis a function, it's called when the reactive object changes (receivesnewValue, oldValue). - (Using a Value as callback for a reactive propertyName is less common and might imply a two-way binding if not careful, however it is permitted).
- You might use this for TextBox.Text properties, such that when the value of the TextBox changes so too does the reactive Value Object's value.
- If
- If
- If
All connections made via these traits are automatically disconnected when the GuiObject they are attached to is destroyed (via instance:Destroy() or Chemical.Destroy()).
6. State Replication
Chemical.Reaction is a singleton service that automates the synchronization of state between the server and connected clients. It supports replicating both static values and reactive Value/Computed objects.
Key Characteristics:
-
Channel & Key Identification: Reactions are identified by a
(channelName: string, reactionKey: string)pair. -
One-Way Server-to-Client: The primary flow of data is from server to client. Client-side changes to replicated state do not automatically propagate back to the server via this system.
-
Nested Structure: Supports state tables with up to one level of nesting where the nested values can be
Valueobjects.-- Example of supported state structure for Reaction local state = { staticTopLevel = "hello", reactiveTopLevel = Chemical.Value(10), nestedObject = { staticNested = true, reactiveNested = Chemical.Value("world") } } -
Tokenization: Internally, channel names, reaction keys, and field keys are tokenized (converted to numbers) for efficient network transmission.
-
Initial Hydration: When a client connects or is ready, it receives a full snapshot of all existing reactions it's concerned with.
-
Delta Updates (for
Valueobjects): Only changes toValueobjects are networked after initial hydration. Changes to static parts of the state after creation are not automatically replicated. If aValueobject itself holds a table, the system is designed to (aims to in the future) send only the changed parts of that table.
Reaction Server-Side API
Accessible via local Reaction = Chemical.Reaction.
Reaction:create(channelName: string, reactionKeyName: string, initialState: table): ServerReactionAPI:- Creates a new reaction on the server and broadcasts its construction to all clients.
initialState: The table defining the reaction's state. Values within this table can be static Luau types orChemical.Value/Chemical.Computedinstances.- Returns a
ServerReactionAPIobject with two methods:destroy(): Destroys the reaction on the server and notifies clients to deconstruct it. All reactiveValueobjects within its state are also destroyed.raw(): Returns a deep, non-reactive snapshot of the current state of the reaction.Valueobjects are replaced with their current values.
Reaction Client-Side API
Accessible via local Reaction = Chemical.Reaction().
Reaction:await(channelName: string, reactionKeyName: string): Promise<ClientReaction>:- Returns with the client-side reaction object once it has been constructed (either through initial hydration or a
ConstructReactionpacket). - The resolved
ClientReactionobject mirrors the structure of the server'sinitialState, where server-sideChemical.Value/Chemical.Computedinstances become client-sideChemical.Valueinstances. Static values remain static.
- Returns with the client-side reaction object once it has been constructed (either through initial hydration or a
Reaction:onCreate(channelName: string, callback: (reactionKeyName: string, reactionObject: ClientReaction) -> ()): () -> ():- Subscribes to creations of new reactions within the specified
channelName. - The
callbackis invoked immediately for any already existing reactions in that channel, and then for any new ones as they are constructed. - Returns a
disconnectFunctionto stop listening.
- Subscribes to creations of new reactions within the specified
Reaction Example Usage
(See Chemical/Examples script for a practical implementation which creates a PlayerData reaction per player.)
Server (ServerScriptService):
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Chemical = require(ReplicatedStorage.Chemical)
local Reaction = Chemical.Reaction -- Get the singleton instance
Players.PlayerAdded:Connect(function(player)
-- Define initial state, some parts reactive, some static
local healthValue = Chemical.Value(100)
local manaValue = Chemical.Value(50)
local playerStatsState = {
PlayerName = player.Name, -- Static
UserId = player.UserId, -- Static
Health = healthValue, -- Reactive
Mana = manaValue, -- Reactive
Inventory = {
Gold = Chemical.Value(10), -- Nested reactive
Items = {"Sword", "Shield"} -- Nested static
}
}
-- Create the reaction for this player
-- The "PlayerData" channel could hold reactions for all players
local myReaction = Reaction:create("PlayerData", tostring(player.UserId), playerStatsState)
print("Created reaction for player:", player.Name)
-- Example of updating a reactive value after creation
task.delay(5, function()
if player and myReaction then -- Ensure player and reaction still exist
print("Server: Setting health for", player.Name, "to 75")
healthValue:set(75) -- This change will be replicated to clients
end
end)
-- When player leaves, destroy their reaction
player.Removing:Connect(function()
if myReaction then
print("Server: Destroying reaction for player:", player.Name)
myReaction:destroy()
end
end)
end)
Client (LocalScript in StarterPlayerScripts):
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Chemical = require(ReplicatedStorage.Chemical)
local Reaction = Chemical.Reaction -- Get the singleton instance
local Create = Chemical.Create
local localPlayer = Players.LocalPlayer
local function setupPlayerUI(playerData)
print("Client: Received PlayerData for", playerData.PlayerName, playerData)
local screenGui = Create("ScreenGui"){ Parent = localPlayer.PlayerGui }
Create("TextLabel"){
Name = "HealthDisplay",
Size = UDim2.fromOffset(200, 30),
Position = UDim2.fromScale(0.5, 0.1),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = Chemical.Computed(function() -- Text reactively updates
return string.format("Name: %s | Health: %d", playerData.PlayerName, playerData.Health:get())
end),
Parent = screenGui,
}
Create("TextLabel"){
Name = "ManaDisplay",
Size = UDim2.fromOffset(200, 30),
Position = UDim2.fromScale(0.5, 0.15),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = Chemical.Computed(function()
return "Mana: " .. playerData.Mana:get()
end),
Parent = screenGui,
}
Create("TextLabel"){
Name = "GoldDisplay",
Size = UDim2.fromOffset(200, 30),
Position = UDim2.fromScale(0.5, 0.2),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = Chemical.Computed(function()
return "Gold: " .. playerData.Inventory.Gold:get()
end),
Parent = screenGui,
}
-- Observe changes locally if needed
Chemical.Observer(playerData.Health):onChange(function(newHealth)
print("Client: Health is now", newHealth)
end)
end
-- Await this specific player's data
Reaction:await("PlayerData", tostring(localPlayer.UserId))
:andThen(setupPlayerUI)
:catch(function(err)
warn("Client: Failed to get PlayerData reaction:", err)
end)
-- Alternatively, listen to all reactions in a channel
-- local disconnectListener = Reaction:onCreate("PlayerData", function(reactionKey, reactionObject)
-- if reactionKey == tostring(localPlayer.UserId) then
-- print("Client: PlayerData (via onCreate) for me!", reactionObject)
-- -- setupPlayerUI(reactionObject)
-- -- if you use onCreate, you might want to manage disconnects or ensure UI is only set up once.
-- else
-- print("Client: PlayerData (via onCreate) for another player:", reactionKey)
-- end
-- end)
7. Client-Side Routing
The Chemical.Router is a singleton service for managing client-side application flow by defining paths and associating them with reactive Chemical.Elements. When the route changes, corresponding Elements are activated or deactivated, typically controlling UI visibility.
API:
Chemical.Router: RouterInstance: Gets the singleton router instance.router:paths(routes: {{ Path: string, Element: Chemical.Element, Persistent?: boolean }}): Defines a set of routes.Path: A string like "/shop/items" or "/profile". Leading/trailing slashes are handled.Element: TheChemical.Elementinstance that will be set totruewhen this path is active.Persistent(optional, boolean): If true, theElementwill not be automatically set tofalsewhen navigating to a sibling or parent. It will still be set tofalseif explicitly exited.
router:to(path: string, params?: table): Navigates to the specifiedpath.- Deactivates elements associated with the previous path (respecting persistence and shared ancestry).
- Activates the
Elementfor the newpath. paramsis an optional table passed to the targetElement's:params()method.params.fromis automatically set to the old path.
router:is(path: string): boolean: Returnstrueif the current path exactly matches the givenpath.router:exit(path: string, params?: table): Explicitly deactivates theElement(and its descendant elements in the route tree) associated with thepath. SetsCurrentPathto"".router:onBeforeChange(callback: (newPath: string, oldPath: string) -> ()): Registers a callback invoked before the current path changes and anyElements close.router:onChange(callback: (newPath: string, oldPath: string) -> ()): Registers a callback invoked whenCurrentPath's value changes. This is syntactic sugar forChemical.Observer(router.CurrentPath):onChange(...).router:onAfterChange(callback: (newPath: string, oldPath: string) -> ()): Registers a callback invoked after the current path has changed and target elements have been updated.router.CurrentPath: Value<string>: A reactiveValueobject holding the current active path string.
Example (from LocalScript Examples):
local Chemical = require(path.to.Chemical)
local Create = Chemical.Create
local Give = Chemical.Give
local Value = Chemical.Value
local Watch = Chemical.Watch
local Ref = Chemical.Ref
local PlayerGui = game.Players.LocalPlayer.PlayerGui
local Router = Chemical.Router -- Get the singleton instance
-- Define Elements for different pages/views
local tutorialPageElement = Chemical.Element()
local homePageElement = Chemical.Element()
local tutorialFrame = Value()
-- Define routes
Router:paths({
{ Path = "/tutorial", Element = tutorialPageElement },
{ Path = "/home", Element = homePageElement, Persistent = true }
})
-- Create UI that reacts to these elements
Create("ScreenGui"){
Parent = PlayerGui,
Children = {
Create("Frame"){ -- Tutorial Page
Name = "TutorialFrame",
Size = UDim2.fromScale(0.8, 0.8),
Position = UDim2.fromScale(0.5, 0.5),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundColor3 = Color3.fromRGB(50, 50, 150),
Visible = tutorialPageElement, -- Reactive visibility
[Ref] = tutorialFrame, --Set the reference to this Frame
Children = {
Create("TextLabel"){ Text = "Tutorial Page", Size = UDim2.fromScale(1,0.1)}
}
},
Create("Frame"){ -- Home Page
Name = "HomeFrame",
Size = UDim2.fromScale(0.7, 0.7),
Position = UDim2.fromScale(0.5, 0.5),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundColor3 = Color3.fromRGB(50, 150, 50),
Visible = homePageElement, -- Reactive visibility
Children = {
Create("TextLabel"){ Text = "Home Page", Size = UDim2.fromScale(1,0.1)}
}
}
}
}
--When the tutorialPageElement params are changed by the Router, we'll apply the specific key `customParam`'s value to the tutorialFrame.
Watch(
function() return tutorialPageElement:params(), "customParam" end,
function(new) Give(tutorialFrame:get()) { Name = new } end
)
-- Listen to route changes
Router:onChange(function(newPath, oldPath)
print(string.format("Route changed from '%s' to '%s'", oldPath, newPath))
end)
-- Navigate
task.wait(1)
print("Navigating to /tutorial")
Router:to("/tutorial", { customParam = "hello from tutorial start" })
task.wait(3)
print("Navigating to /home")
Router:to("/home") -- tutorialPageElement becomes false, homePageElement becomes true
task.wait(3)
print("Navigating to /tutorial again")
Router:to("/tutorial") -- homePageElement remains true (persistent), tutorialPageElement becomes true
task.wait(3)
print("Exiting /tutorial (not /home because it's persistent from this level)")
Router:exit("/tutorial") -- tutorialPageElement becomes false
task.wait(3)
print("Exiting /home")
Router:exit("/home") -- homePageElement becomes false
8. Utility Functions
Chemical Await
Chemical.Await(chemicalObject: Value<any> | Computed<any>): TYields the current thread until the providedchemicalObject(aValue,Computed,Element, orRouter) changes its value at least once afterAwaitis called. It resolves with no arguments once the change occurs. Essentially, it's a promise that resolves on the next change.- Note: This uses
Promise.newinternally andobserver:onChange. The actual return valueThere is effectivelyvoidfrom the promise's perspective (it resolves with no values). Its primary use is to pause execution until a specific reactive data point is known to have received an update.
- Note: This uses
Chemical Destroy
Chemical.Destroy(subject: Destroyable)A versatile cleanup function that attempts to properly destroy or disconnect various types of objects:- Objects with a
:destroy()(or:Destroy()) method (like Chemical primitives). - Tables: Clears them and sets their metatable to nil. If elements within are
Destroyable, recursively callsChemical.Destroyon them. - Roblox
Instances: Callsinstance:Destroy(). RBXScriptConnections: Callsconnection:Disconnect(). - In the future, it will be possible to handle ConnectionLike objects.- Functions: Calls the function (intended for disconnect functions).
- Threads: Calls
task.cancel(thread). - Tables: Recurscively calls
Chemical.Destroy(Table).
- Objects with a
Chemical Nothing
Chemical.Nothing()As the name implies, it does nothing. This can be useful when you want to specify that the cleanup of a Computed or Iterator should do nothing.
9. Under The Hood (Advanced)
Networking
The Chemical.Reaction system leverages these Packet definitions for its Construct, Deconstruct, UpdateRoot, UpdateNested, Ready, and Hydrate operations.
10. Type System
Chemical heavily utilizes Luau's type annotation system for improved code clarity, maintainability, and editor intellisense.
- Root
Types.lua: Defines the public interface types for core Chemical objects likeValue<T>,Computed<T>,Observer,Element, andReaction. Types/Gui.lua&Types/Gui/Overrides.lua: Provide exhaustive type definitions for Roblox GUI object properties (FrameProperties,TextLabelProperties, etc.) and event names/signatures. These are crucial for the type-safe/intellisense usage ofChemical.CreateandChemical.Give.- Inline Type Annotations: Throughout the codebase, functions, variables, and table fields are typed.
11. Examples
The Chemical/Examples (Script) and Chemical/Examples (LocalScript) provide practical demonstrations:
- Server-Side (
ExamplesScript):- Demonstrates creating a per-player
Reactionwith nestedValueobjects. - Shows how the server defines the reaction's state and structure.
- The
export type Typedefines the expected structure of the client-side reaction object for better type safety on the client.
- Demonstrates creating a per-player
- Client-Side (
ExamplesLocalScript):- Uses
Chemical.Routerto define simple routes. - Uses
Chemical.Createto build UI elements. - Demonstrates binding UI properties (like
VisibleandBackgroundColor3) toChemical.ElementandChemical.Valueobjects, respectively. - Shows how to use
[Chemical.Ref]to get a reference to a created UI element. - Illustrates connecting to UI events using
[Chemical.onEvent]. - Shows basic router navigation with
:to()and:exit().
- Uses