.gitignore | ||
Chemical.rbxm | ||
LICENSE | ||
README.md |
Chemical - Reactive State & UI Framework for Roblox Luau
Version: 0.1.0b ALPHA (as of 6/1/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.Reaction
facilitates 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
Chemical
rootModuleScript
(and its descendant file structure from the.rbxm
) into a suitable location, typicallyReplicatedStorage
to be accessible by both server and client. - Ensure the
UseReactions
BoolValue inChemical/Configuration
is set totrue
if you intend to use theChemical.Reaction
state replication system. Iffalse
, attempting to useReaction
will 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 Observer
s depending on it are notified.
API:
Chemical.Value(initialValue: T): Value<T>
: Constructor.:get(): T
: Retrieves the current value. If called within aComputed
function orWatch
target getter, it registers thisValue
as 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 numericValue
s. Increments the value byamount
(defaults to 1).:toggle()
: For booleanValue
s. Flips the boolean state.:key(key: any, newValue: any)
: For tableValue
s. Setstbl[key] = newValue
and triggers updates.:insert(itemValue: any)
: For array-like tableValue
s. Equivalent totable.insert(tbl, itemValue)
.:remove(itemValue: any)
: For array-like tableValue
s. Removes the first occurrence ofitemValue
.:destroy()
: Destroys theValue
object, cleaning up its ECS entity and notifying dependentComputed
s orObserver
s (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 (Value
s or other Computed
s). 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 theComputed
is re-cached due to a dependency change, or when theComputed
object 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 anotherComputed
orObserver
, it registers thisComputed
as a dependency.:destroy()
: Destroys theComputed
object, cleaning up its value ifcleanup
was provided as well as its ECS entity and notifying dependentComputed
s orObserver
s (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
disconnectFunction
that, when called, unregisters this specific callback.
- Returns a
:destroy()
: Destroys theObserver
and 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 Element
s 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. TheRouter
uses 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 thefrom
property 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 tofalse
when navigating away from its associated path.
Example (Conceptual, often used with Router):
local Chemical = require(path.to.Chemical)
local settingsPageElement = Chemical.Element()
-- In UI Creation:
-- Visible = settingsPageElement,
routher: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
Value
orComputed
object that holds the table you want to watch. - The specific
key
within 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 Element
s.
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
Value
orComputed
object:Visible = myVisibilityValue
(wheremyVisibilityValue
is 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
callback
is a function, it's called when the event fires. Arguements of the event are passed to the function. - If
callback
is aValue<boolean>
, its value is set totrue
when 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
propertyName
is a string: Listens toInstance:GetPropertyChangedSignal(propertyName)
.- If
callback
is a function, it's called with the new property value.- If
callback
is aValue
, itsset
method is called with the new property value.
- If
- If
propertyName
is aValue
,Computed
, orElement
(reactive object): Creates anObserver
for this reactive object.- If
callback
is 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
)
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
Value
objects.-- 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
Value
objects): Only changes toValue
objects are networked after initial hydration. Changes to static parts of the state after creation are not automatically replicated. If aValue
object 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.Computed
instances.- Returns a
ServerReactionAPI
object with two methods:destroy()
: Destroys the reaction on the server and notifies clients to deconstruct it. All reactiveValue
objects within its state are also destroyed.raw()
: Returns a deep, non-reactive snapshot of the current state of the reaction.Value
objects 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 a
Promise
that resolves with the client-side reaction object once it has been constructed (either through initial hydration or aConstructReaction
packet). - The resolved
ClientReaction
object mirrors the structure of the server'sinitialState
, where server-sideChemical.Value
/Chemical.Computed
instances become client-sideChemical.Value
instances. Static values remain static. - It's recommended to use
:expect()
on the promise if you are certain the reaction should exist, to automatically error if it doesn't resolve.
- Returns a
Reaction:onCreate(channelName: string, callback: (reactionKeyName: string, reactionObject: ClientReaction) -> ()): () -> ()
:- Subscribes to creations of new reactions within the specified
channelName
. - The
callback
is invoked immediately for any already existing reactions in that channel, and then for any new ones as they are constructed. - Returns a
disconnectFunction
to 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 (Chemical.Router
)
The Chemical.Router
is a singleton service for managing client-side application flow by defining paths and associating them with reactive Chemical.Element
s. When the route changes, corresponding Element
s 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.Element
instance that will be set totrue
when this path is active.Persistent
(optional, boolean): If true, theElement
will not be automatically set tofalse
when navigating to a sibling or parent. It will still be set tofalse
if 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
Element
for the newpath
. params
is an optional table passed to the targetElement
's:params()
method.params.from
is automatically set to the old path.
router:is(path: string): boolean
: Returnstrue
if 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
. SetsCurrentPath
to""
.router:onBeforeChange(callback: (newPath: string, oldPath: string) -> ())
: Registers a callback invoked before the current path changes and anyElement
s 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 reactiveValue
object 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>): T
Yields the current thread until the providedchemicalObject
(aValue
,Computed
,Element
, orRouter
) changes its value at least once afterAwait
is 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.new
internally andobserver:onChange
. The actual return valueT
here is effectivelyvoid
from 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.Destroy
on them. - Roblox
Instance
s: Callsinstance:Destroy()
. RBXScriptConnection
s: 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
9. Under The Hood (Advanced)
Networking (Packages.Packet
)
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.Create
andChemical.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 (
Examples
Script):- Demonstrates creating a per-player
Reaction
with nestedValue
objects. - Shows how the server defines the reaction's state and structure.
- The
export type Type
defines the expected structure of the client-side reaction object for better type safety on the client.
- Demonstrates creating a per-player
- Client-Side (
Examples
LocalScript):- Uses
Chemical.Router
to define simple routes. - Uses
Chemical.Create
to build UI elements. - Demonstrates binding UI properties (like
Visible
andBackgroundColor3
) toChemical.Element
andChemical.Value
objects, 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