Charm

repository·main·Indexed 42 days ago

https://github.com/littensy/charm

A fine-grained reactivity library for Roblox based on alien-signals, providing atomic state management through signals, effects, and computed values. Version 0.11.0 includes tools for managing game state, batching updates, and a companion package, @rbxts/charm-sync, for replicating state from server to clients using delta-compressed patches.

Tokens
15.2K
Snippets
70
Records
75
Agent score
53%

What's inside Charm

  1. Understanding Delta Compression and the None value

    main

    Charm Sync uses delta compression to send only changed values. In tables, unchanged properties are omitted (set to nil).

    Because nil is used to represent omitted properties, Charm uses a special value to represent an actual deleted value: { __none = "__none" }.

    Developer Note: If you are manually processing update payloads (e.g., for custom serialization), you must account for the fact that nilable values may be replaced by this None object.

  2. Migrate to Charm v0.11

    main

    Charm v0.11 introduced breaking changes. Key migration points include:

    1. API Renames & Changes:
      • peek() $\rightarrow$ untracked()
      • atom(value, options) $\rightarrow$ atom(value, equalityFunction)
      • computed(getter, equality) $\rightarrow$ computed(getter) (the callback now receives the previous value for manual equality checks).
      • Removed the cleanup argument from effect callbacks.
    2. Charm Sync: Modules now act as singletons. Use CharmSync.client or CharmSync.server directly.
    3. Strict/Frozen Flags: In Roblox Studio, strict (errors on yielding in effects/signals) and frozen (deeply freezes tables in signals) are enabled by default.
    4. Nested Effects: Effects created inside another effect are now automatically cleaned up when the parent re-runs or is disposed. To prevent this, wrap the inner effect in untracked(). You can temporarily disable this via flags.trackInnerEffects = false.
  3. How widgets and connectors work together in Charm

    main

    Charm uses a fine-grained reactivity model to manage game state. The core building blocks are:

    • Signals: Reactive state containers that hold values. You interact with them via a getter and a setter.
    • Effects: Code blocks that re-run automatically whenever the signals they access change.
    • Computed Signals: Memoized functions that derive new values from other signals. They only re-calculate when their dependencies change.

    Example: Reactive Todo List

    This example demonstrates creating a reactive UI where a list of todos is observed, and each todo item is represented by a TextLabel that updates its text and visibility based on a search query.

    local getTodos, setTodos = signal({} :: { string })
    local getQuery, setQuery = signal("")
    
    -- observe reacts to changes in the getTodos signal
    observe(getTodos, function(todo: string, index: number)
    	local instance = Instance.new("TextLabel")
    
    	-- getText is a computed signal that only re-runs if the specific todo at this index changes
    	local getText = computed(function()
    		return getTodos()[index] or ""
    	end)
    
    	-- effect re-runs whenever getText() or getQuery() changes
    	effect(function()
    		instance.Text = getText()
    		instance.Visible = string.match(getText(), getQuery()) ~= nil
    	end)
    
    	instance.LayoutOrder = index
    	instance.Size = UDim2.new(1, 0, 0, 40)
    	instance.Parent = screenGui
    
    	-- The return function acts as a cleanup callback when the item is removed from the list
    	return function()
    		instance:Destroy()
    	end
    end)
    
    setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
    setQuery("Buy")
    local getTodos, setTodos = signal({} :: { string })
    local getQuery, setQuery = signal("")
    
    observe(getTodos, function(todo: string, index: number)
    	local instance = Instance.new("TextLabel")
    
    	local getText = computed(function()
    		return getTodos()[index] or ""
    	end)
    
    	effect(function()
    		instance.Text = getText()
    		instance.Visible = string.match(getText(), getQuery()) ~= nil
    	end)
    
    	instance.LayoutOrder = index
    	instance.Size = UDim2.new(1, 0, 0, 40)
    	instance.Parent = screenGui
    
    	return function()
    		instance:Destroy()
    	end
    end)
    
    setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
    setQuery("Buy")
  4. Install Deep Charm for deep reactivity

    main

    Charm is shallow by default (only top-level values are reactive). To enable deep reactivity—where property changes in tables are tracked via proxies—install the @rbxts/deep-charm library.

    WARNING

    Deep Charm is an experimental library and its API is subject to breaking changes.

    npm install @rbxts/deep-charm
    yarn add @rbxts/deep-charm
    pnpm add @rbxts/deep-charm
    [dependencies]
    DeepCharm = "littensy/deep-charm@VERSION"
  5. Implement Client-Server State Sync

    main

    Charm Sync allows you to replicate state from the server to clients using signals, atoms, or computed values.

    Workflow

    1. Server: Define state in a store. Use server.addSignalsToClient to subscribe players to specific signals. Use server.connect to define how updates are transmitted (e.g., via a RemoteEvent).
    2. Client: Define local writable signals. Use client.addSignals to map server keys to these local setters. Use client.patch to apply incoming updates from the server.

    Important: Key Uniqueness

    On the server, every player must have the same key for global signals. For player-specific signals, you must use a unique key (e.g., using player.UserId) to avoid collisions and warnings. If two players are assigned different signals for the same key, Charm will emit a warning.

    -- SERVER SETUP
    playerReadyEvent.OnServerEvent:Connect(function(player)
    	server.addSignalsToClient(player, {
    		status = store.getStatus,
    		-- Use unique keys for player-specific data
    		[`inventory-{player.UserId}`] = computed(function()
    			return store.getInventories()[player.Name]
    		),
    	})
    end)
    
    server.connect(function(player, updates)
    	syncEvent:FireClient(player, updates)
    end)
    
    -- CLIENT SETUP
    local getInventory, setInventory = signal({} :: Inventory)
    
    client.addSignals({
    	status = store.setStatus,
    	[`inventory-{Players.LocalPlayer.UserId}`] = setInventory,
    })
    
    syncEvent.OnClientEvent:Connect(function(updates)
    	client.patch(updates)
    end)
  6. Install Charm

    main

    You can install Charm using your preferred package manager or add it to your wally.toml for Roblox development.

    npm/yarn/pnpm

    npm install @rbxts/charm
    yarn add @rbxts/charm
    pnpm add @rbxts/charm

    Wally

    Add the following to your wally.toml:

    [dependencies]
    Charm = "littensy/charm@VERSION"
    npm install @rbxts/charm
  7. Configure Charm behavior with `flags`

    main

    Charm provides global flags to customize its reactivity engine behavior:

    FlagDefaultDescription
    stricttrueEnforces synchronous, non-yielding behavior in signals and effects.
    frozentrueEnforces data immutability by deep-freezing tables passed to signals (excluding objects with metatables).
    trackInnerEffectstrueDetermines if nested effects are tracked and cleaned up when the parent effect re-runs.

    Note: strict and frozen are automatically enabled in Roblox Studio.

  8. Configure Charm Sync server behavior

    main

    The config table allows customizing server-side replication behavior.

    # Reference of available configuration options
    # interval: frequency in seconds (0 = next frame, negative = disabled)
    # preserveHistory: boolean, preserves full history of changes
    # fixArrays: boolean, attempts to work around remote event array limitations (default true)
    # validatePatches: boolean, emits warnings for unsafe sparse arrays if fixArrays/strict mode enabled
  9. Integrate Charm with Vide

    main

    Use vide-charm to bridge Charm signals with Vide. The useSignalState hook allows you to consume a Charm signal within a Vide component.

    local Charm = require("@packages/charm")
    local VideCharm = require("@packages/vide-charm")
    local Vide = require("@packages/vide")
    
    local create = Vide.create
    
    local getCounter, setCounter = Charm.signal(0)
    
    local function Counter()
    	return create "TextButton" {
    		Activated = function()
    			setCounter(function(count)
    				return count + 1
    			end)
    		end,
    		Text = function()
    			return `Count: {count()}`
    		end,
    		Size = UDim2.fromOffset(100, 50),
    	}
    end
  10. Integrate Charm with React

    main

    Use react-charm to bridge Charm signals with React state. The useSignalState hook allows you to consume a Charm signal within a React component.

    local Charm = require("@packages/charm")
    local ReactCharm = require("@packages/react-charm")
    local React = require("@packages/react")
    
    local getCounter, setCounter = Charm.signal(0)
    
    local function Counter()
    	local count = ReactCharm.useSignalState(getCounter)
    
    	return React.createElement("TextButton", {
    		[React.Event.Activated] = function()
    			setCounter(count + 1)
    		end,
    		Text = `Count: {count}`,
    		Size = UDim2.fromOffset(100, 50),
    	})
    end