Appearance
State with charm
charm atoms are the state behind TypeTorch's UI workflow, and charm-sync sends the server's atoms to clients. Both work as usual on TypeTorch. This page is the few lines that are TypeTorch's.
Atoms are per generation. A swap requires your modules again, so every atom is created again with its default value, on the server and on every client. Three things keep state across swaps:
- The server's atoms start from
persistand write every change back. - charm-sync goes over
createNetwork: one leaf with anunknownpayload. No RemoteEvents. - Each client asks for a full state in its controller's
onStart, so it hydrates after every join and after its own swap. Until the answer arrives, its atoms keep the values it had before the swap (from its ownpersist).
Written for charm 0.10 and charm-sync 0.3, the pair most roblox-ts games use. charm-sync 0.4 (which needs charm 0.11) replaced the CharmSync.server(...) / CharmSync.client(...) objects with module functions (server.connect, client.patch, ...), so adapt the charm-sync calls below if you use it. With other versions, or another sync library, the TypeTorch lines are the same.
Without charm-sync, the same rule holds: the template's RushStateController keeps client atoms on the controller instance (fresh each generation), fills them from network events, and asks the server for a snapshot after every join and after its own swap.
The atoms
ts
// src/shared/state/synced.ts
import { atom, subscribe, type Atom } from "@rbxts/charm";
import type { Trove } from "@rbxts/trove";
import { TypeTorch } from "@typetorch/framework";
export interface RoundState {
phase: "lobby" | "round";
endsAt: number;
}
export const roundAtom = atom<RoundState>({ phase: "lobby", endsAt: 0 });
/** UserId (as a string: string keys survive remotes) -> score. */
export const scoresAtom = atom<Record<string, number>>({});
/** Every atom the server syncs to clients: one list, used by both realms. */
export const SYNCED = { round: roundAtom, scores: scoresAtom };
/**
* Atoms are created again in every generation (this module is new after a swap). Starts them from the last
* generation's values and writes every change back, through `persist` (plain values, never the atoms).
*/
export function persistAtoms(trove: Trove, key: string) {
const saved = TypeTorch.persist(key, () => ({}) as Record<string, unknown>);
const atoms = SYNCED as unknown as Record<string, Atom<unknown>>;
for (const [name, value] of pairs(saved)) atoms[name]?.(value);
for (const [name, synced] of pairs(atoms)) trove.add(subscribe(synced, (value) => (saved[name] = value)));
}The subscriptions go in the module's trove, so the old generation stops writing when it stops.
The network
ts
// src/shared/net.ts
import { createNetwork, setNetworkLimits } from "@typetorch/framework";
interface ClientToServer {
state: {
/** "Send me everything": after every join and every swap. */
hydrate(): void;
};
}
interface ServerToClient {
state: {
/** charm-sync payloads (init or patch), passed through as they are. */
sync(payloads: unknown): void;
/** This player's own state (a syncer per player, below). */
mine(payloads: unknown): void;
};
}
export const network = createNetwork<ClientToServer, ServerToClient>();
// A client asks once per generation: a small bucket is plenty.
setNetworkLimits({ "state.hydrate": { rate: [3, 0.2] } });unknown is right here: the server sends these, and clients trust the server. The only client -> server message is the argument-free hydrate, which the server checks and rate-limits like any leaf.
Server
ts
// src/server/services/state.service.ts
import CharmSync from "@rbxts/charm-sync";
import { Module, Service, type OnInit } from "@typetorch/framework";
import { network } from "../../shared/net";
import { persistAtoms, SYNCED } from "../../shared/state/synced";
@Service({ loadOrder: -20 }) // before the modules that read or write the atoms
export class StateService extends Module implements OnInit {
onInit() {
// 1. The new generation's atoms start with the old generation's values.
persistAtoms(this.trove, "state.synced.v1");
// 2. charm-sync over the game's network: patches to every player, a full state to a player who asks.
const syncer = CharmSync.server({ atoms: SYNCED, interval: 0.2 });
this.trove.add(syncer.connect((player, ...payloads) => network.server.state.sync.fire(player, payloads)));
this.trove.add(network.server.state.hydrate.on((player) => syncer.hydrate(player)));
}
}persistAtomsruns beforeCharmSync.server, so the syncer starts from the restored values.syncer.connectreturns its cleanup (the patch timer and subscriptions): in the trove, it ends with the generation.- The server doesn't push a full state by itself: a swap restarts every client too, and each one asks.
Client
ts
// src/client/controllers/state.controller.ts
import CharmSync from "@rbxts/charm-sync";
import { Controller, Module, type OnInit, type OnStart } from "@typetorch/framework";
import { network } from "../../shared/net";
import { persistAtoms, SYNCED } from "../../shared/state/synced";
@Controller({ loadOrder: -20 }) // before the UI controllers that draw from the atoms
export class StateController extends Module implements OnInit, OnStart {
onInit() {
// The values this client had before its own swap: the UI keeps them (no flash of defaults) until the hydrate.
persistAtoms(this.trove, "state.synced.v1");
const syncer = CharmSync.client({ atoms: SYNCED });
this.trove.add(
network.client.state.sync.on((payloads) => syncer.sync(...(payloads as CharmSync.SyncPayload<typeof SYNCED>[]))),
);
}
onStart() {
// A full state after every join and every swap (charm-sync ignores patches until it has one).
network.client.state.hydrate.fire();
}
}- The listener is registered in
onInit, the request goes out inonStart: the answer can't arrive before anyone listens. - A client's
persistis its own memory (the server never sees it). It lasts until the player leaves.
Per player
charm-sync sends every player the same patches. Don't filter a patch per player (dropping keys from it): the client applies it to what it has, and a filtered patch no longer matches. Give each player a syncer of their own over a selector of their part instead:
ts
// src/server/services/quest.service.ts
import { atom } from "@rbxts/charm";
import CharmSync from "@rbxts/charm-sync";
import type { Trove } from "@rbxts/trove";
import { Module, Service, type OnPlayerAdded } from "@typetorch/framework";
import { network } from "../../shared/net";
/** Server-only: every player's quests, by UserId as a string. */
const questsAtom = atom<Record<string, { done: number }>>({});
@Service()
export class QuestService extends Module implements OnPlayerAdded {
onPlayerAdded(player: Player, playerTrove: Trove) {
// One syncer per player, over a selector of that player's part: nobody else's data reaches them.
const key = tostring(player.UserId);
const syncer = CharmSync.server({ atoms: { quests: () => questsAtom()[key] }, interval: 0.2 });
playerTrove.add(
syncer.connect((target, ...payloads) => {
if (target === player) network.server.state.mine.fire(player, payloads);
}),
);
playerTrove.add(network.server.state.hydrate.on((from) => from === player && syncer.hydrate(player)));
}
}On the client, a second CharmSync.client({ atoms: { quests: myQuestsAtom } }) listens to state.mine the same way. onPlayerAdded replays every player after a swap, and the player trove ends each syncer when the player leaves. Keep the server-only atom's values across swaps the same way (persistAtoms-style, or this.ctx.playerState).
Patterns that survive swaps
- Derive from state, not from events. Atoms that are synced and persisted (
persistAtoms) keep their last values across a client swap, and the server sends them again on the hydrate. A client that derives what should exist from them (a held tool's idle animation, a flight effect, a mod flag) redraws it after every swap by itself. A "play" / "stop" event pair breaks: the new generation never saw the "play". - Deleted keys travel as
None. Setting a key toundefinedon the server reaches the client as charm-sync'sNonemarker. Code that reads raw payloads checksisNone(value)(from@rbxts/charm-sync), nevervalue === undefined. - Prune per-player keys on a real leave. A dictionary keyed by UserId keeps every player who ever joined. One server module removes the key:
this.trove.connect(Players.PlayerRemoving, (player) => ...). Not aplayerTrovecleanup, which also runs on every swap. - Clients can't write synced atoms. charm-sync goes server to client only. A client toggle other players must see (an emote, a setting shown on the character) is a network leaf the server checks, then writes into the atom.
Rules
persistholds values, never atoms: an atom keeps the old generation's code alive.- Plain data in atoms you persist or sync: tables, arrays, strings, numbers, booleans. Version the persist key (
state.synced.v2) when a shape changes in a way old values would break. - String keys for dictionaries you sync (
tostring(userId)): a table keyed by numbers that aren't 1, 2, 3... doesn't cross a remote intact. - One
SYNCEDlist for both realms, and only atoms in it:computedvalues are derived again on each side. - Draw UI from the atoms, never from a remote's answer (the TypeTorch UI workflow), so a swap's hydrate redraws it.