Use when writing reactive state in Luau with Charm (littensy/charm) - signals, atoms, computed values, effects, observers - or syncing server state to clients with Charm Sync, or binding Charm state to Vide or React UI. Covers the Charm 0.11 API and the mistakes that throw in Studio.
Use the robloxvibecoders MCP: call get_resource with id "skill-roblox-charm" and install it as a Claude Code skill in this project (.claude/skills/roblox-charm/SKILL.md).
Skills in ~/.claude/skills work in every project. Put them in your project's .claude/skills folder to share them with your team.
What your AI reads
SKILL.md
---
name: roblox-charm
description: Use when writing reactive state in Luau with Charm (littensy/charm) - signals, atoms, computed values, effects, observers - or syncing server state to clients with Charm Sync, or binding Charm state to Vide or React UI. Covers the Charm 0.11 API and the mistakes that throw in Studio.
---
# Atomic state with Charm
[Charm](https://github.com/littensy/charm) is fine-grained reactive state for Roblox. Store values in **signals**,
derive values with **computed**, and react with **effects**. This skill targets **Charm 0.11**. Older code using
`peek`, `atom(value, options)` or `CharmSync.server({...})` is pre-0.11: see "Migrating" below.
## Install (Wally)
```toml
[dependencies]
Charm = "littensy/charm@0.11.1"
CharmSync = "littensy/charm-sync@0.4.1" # server -> client sync
VideCharm = "littensy/vide-charm@0.4.1" # if the UI uses Vide
ReactCharm = "littensy/react-charm@0.4.1" # if the UI uses React
```
Check the newest versions with the Vibecoders MCP (`get_package` "littensy/charm") before pinning.
## The building blocks
```luau
local Charm = require(ReplicatedStorage.Packages.Charm)
local signal, atom, computed, effect = Charm.signal, Charm.atom, Charm.computed, Charm.effect
-- signal: separate getter and setter
local getCoins, setCoins = signal(0)
setCoins(10)
setCoins(function(coins) return coins + 5 end) -- update from the current value
print(getCoins()) -- 15
-- atom: one function that gets (no args) and sets (one arg)
local roundStatus = atom("intermission")
roundStatus("playing")
print(roundStatus()) -- "playing"
-- computed: derived, cached, read-only. Re-runs only when a dependency changed.
local canAffordSword = computed(function()
return getCoins() >= 100
end)
-- effect: runs now, and again whenever a signal it read changes.
-- Return a function to clean up before the next run or on dispose.
local dispose = effect(function()
label.Text = `Coins: {getCoins()}`
end)
```
Use `signal` when only some code should be able to write (export the getter, keep the setter private). Use `atom`
for simple shared values.
## Reacting to changes
| Function | Runs immediately? | Tracks | Use for |
|---|---|---|---|
| `effect(fn)` | yes | everything `fn` reads | keeping something in sync with state |
| `listen(getter, fn)` | yes | only `getter` | side effects that read other signals without subscribing to them |
| `subscribe(getter, fn)` | no, only on change | only `getter` | "when X changes, do Y" (sounds, analytics, saving) |
| `observe(getter, fn)` | once per key | keys of a table | creating and destroying something per item (per player, per inventory slot) |
```luau
Charm.subscribe(getCoins, function(coins, previous)
if coins > previous then playSound("coin") end
end)
-- One button per inventory item; removed items clean themselves up.
Charm.observe(getInventory, function(item, id)
local button = makeItemButton(item)
button.Parent = grid
return function()
button:Destroy()
end
end)
```
Getters can be any function that reads signals: `listen(function() return math.floor(getXp() / 100) end, ...)`
fires only when the level actually changes.
More tools:
- `batch(fn)`: make several writes, effects run once at the end.
- `untracked(fn)`: read signals inside an effect without subscribing (was `peek` before 0.11).
- `effectScope(fn)`: group effects and dispose them together. `onCleanup(fn)` adds a cleanup to the current
effect or scope.
- `mapped(getter, transform)`: a signal of a table, transformed per key.
- `trigger(fn)`: tell dependents a value changed after mutating it in place (prefer immutable updates instead).
## Immutable updates (this throws in Studio otherwise)
Charm compares by reference, and in Studio the `frozen` flag deep-freezes tables you put in a signal. Mutating a
stored table errors in Studio and silently fails to update in a live server. Always copy, change, set:
```luau
setInventory(function(inventory)
local nextInventory = table.clone(inventory)
nextInventory[itemId] = { count = (inventory[itemId] and inventory[itemId].count or 0) + 1 }
return nextInventory
end)
```
The `strict` flag (also on in Studio) errors if an effect, computed or signal callback yields. No `task.wait`,
`:WaitForChild` or DataStore calls inside them: do the yielding work outside and write the result into a signal.
## Structure
```luau
-- ReplicatedStorage/Shared/Store.luau: one place that owns shared state
local Charm = require(ReplicatedStorage.Packages.Charm)
local getRound, setRound = Charm.signal("intermission")
local getTimer, setTimer = Charm.signal(0)
local getPlayers, setPlayers = Charm.signal({} :: { [string]: PlayerState })
return {
getRound = getRound, setRound = setRound,
getTimer = getTimer, setTimer = setTimer,
getPlayers = getPlayers, setPlayers = setPlayers,
}
```
Load saved data into the store when a player joins, write it back from the store when they leave. Charm is the
runtime state, your data layer is the storage.
## Syncing server state to clients (Charm Sync)
The server picks which signals each client receives; changes go out as delta patches over one remote. In 0.4+,
`CharmSync.server` and `CharmSync.client` are singletons.
```luau
-- Server
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local server = CharmSync.server
ready.OnServerEvent:Connect(function(player)
server.addSignalsToClient(player, {
round = Store.getRound,
timer = Store.getTimer,
-- Per-player state needs a key unique to that player
[`player-{player.UserId}`] = Charm.computed(function()
return Store.getPlayers()[tostring(player.UserId)]
end),
})
end)
Players.PlayerRemoving:Connect(function(player)
server.removeClient(player)
end)
server.connect(function(player, updates)
syncRemote:FireClient(player, updates)
end)
```
```luau
-- Client
local client = CharmSync.client
local getMe, setMe = Charm.signal(nil :: PlayerState?)
client.addSignals({
round = Store.setRound,
timer = Store.setTimer,
[`player-{Players.LocalPlayer.UserId}`] = setMe,
})
syncRemote.OnClientEvent:Connect(function(updates)
client.patch(updates)
end)
ready:FireServer()
```
- Only sync what the client needs. Never sync other players' private data.
- Removed values arrive as `{ __none = "__none" }` in patches. Handle that if you serialize the payload yourself.
- The client copy is for display. Requests still go to the server through remotes, and the server validates.
- `Charm.signalToAtom(getter, setter)` lets one table of atoms serve both `addSignals` and `addSignalsToClient`.
## UI
```luau
-- Vide
local VideCharm = require(ReplicatedStorage.Packages.VideCharm)
local coins = VideCharm.useSignalState(Store.getCoins)
create "TextLabel" { Text = function() return `Coins: {coins()}` end }
-- React
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local coins = ReactCharm.useSignalState(Store.getCoins)
```
Without a UI library, bind with `effect()` and dispose it when the UI is destroyed.
## Migrating from Charm before 0.11
- `peek()` is now `untracked()`.
- `atom(value, { equals = ... })` is now `atom(value, equalsFn)`.
- `computed(fn, equals)` lost its second argument; `fn` receives the previous value instead.
- Effects no longer get a `cleanup` argument: return a cleanup function or call `onCleanup`.
- Charm Sync: replace `CharmSync.server({ atoms = ... })` syncers with `server.addSignalsToClient` per player and
`client.addSignals` + `client.patch`.
- Nested effects now clean up with their parent. Wrap ones that must outlive it in `untracked()`.