@typetorch/framework 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,532 +1,611 @@
1
- # @typetorch/framework
2
-
3
- The roblox-ts framework of [TypeTorch](https://github.com/typetorch): hot-swappable modules (dependency injection,
4
- lifecycle, troves), guarded networking over the kernel's stable remotes, UI helpers and the in-game dev menu.
5
-
6
- It ships **inside every artifact**, so each generation runs its own fresh copy and framework fixes hot-swap like game
7
- code. It needs the TypeTorch kernel in the place (`@typetorch/kernel`), which calls your `Server/boot` and
8
- `Client/boot` modules.
9
-
10
- ## Install
11
-
12
- ```sh
13
- npm i @typetorch/framework
14
- npm i -D @typetorch/transformer
15
- ```
16
-
17
- (`bun add @typetorch/framework` and `bun add -d @typetorch/transformer` work the same.) The framework is a roblox-ts 3
18
- package; `@typetorch/transformer` is its compiler plugin: it generates the network guards, the constructor dependency
19
- ids of `@Service` / `@Controller` and your own macros. Nothing from Flamework is needed: `Modding`, `Reflect` and `t`
20
- come from this package. The starter game ([template](https://github.com/typetorch/template)) has it all set up: clone
21
- it and run `bun install`.
22
-
23
- `tsconfig.json`:
24
-
25
- ```jsonc
26
- {
27
- "compilerOptions": {
28
- "experimentalDecorators": true,
29
- // Every npm scope the game imports from must be a type root (roblox-ts rule).
30
- "typeRoots": ["node_modules/@rbxts", "node_modules/@typetorch"],
31
- "types": ["types", "compiler-types"],
32
- "plugins": [
33
- // Optional, first when present: $print/$warn with file and line (what every TypeTorch repo uses).
34
- { "transform": "rbxts-transform-debug", "environmentRequires": {} },
35
- { "transform": "@typetorch/transformer" }
36
- ]
37
- }
38
- }
39
- ```
40
-
41
- The payload is a Rojo `Model` project. Map only this package from the `@typetorch` scope (the transformer is Node code
42
- and the kernel lives in the place):
43
-
44
- ```json
45
- "include": {
46
- "$path": "include",
47
- "node_modules": {
48
- "$className": "Folder",
49
- "@rbxts": { "$path": "node_modules/@rbxts" },
50
- "@typetorch": { "$className": "Folder", "framework": { "$path": "node_modules/@typetorch/framework" } }
51
- }
52
- }
53
- ```
54
-
55
- Each generation requires its own copy of the payload, so the framework and its `Reflect` registry start fresh on every
56
- swap. It needs the TypeTorch kernel in the place (`@typetorch/kernel`).
57
-
58
- ### Coming from Flamework
59
-
60
- Game code only changes imports: `@Service`, `@Controller`, constructor injection, `Dependency<T>()`, the lifecycle
61
- interfaces and `createNetwork` are the same.
62
-
63
- | Flamework | TypeTorch |
64
- |---|---|
65
- | `rbxts-transformer-flamework` plugin, `node_modules/@flamework` type root | `@typetorch/transformer` plugin, `node_modules/@typetorch` type root |
66
- | `import { Modding, Reflect } from "@flamework/core"` | `import { Modding, Reflect } from "@typetorch/framework"` |
67
- | `import { Dependency } from "@flamework/core"` | `import { Dependency } from "@typetorch/framework"` (from onInit/onStart on, not in constructors) |
68
- | `import type` + `Dependency<T>()` to break a cycle | the same, or `Lazy<T>` (below) |
69
- | `ClientEvents.x.predict(...)` | `network.client.x.emit(...)` |
70
- | `invokeWithTimeout(timeout, ...)` | `invokeWithTimeout(seconds, ...)` (seconds, 0.5 to 120) |
71
- | `Observers.observeCharacter` / `observeLocalCharacter` (@rbxts/observers) | still fine: put the stop function in the trove, `this.trove.add(observeCharacter(...))` |
72
- | `import { t } from "@rbxts/t"` (still fine) | also `import { t } from "@typetorch/framework"` |
73
- | `@metadata flamework:parameters` keys, `"flamework:parameters"` metadata | `@metadata typetorch:parameters`, `"typetorch:parameters"` |
74
- | `flamework.build`, `include/flamework`, the `@flamework` Rojo mapping | gone; delete them |
75
- | `Networking.createEvent` / `createFunction` and their call sites | `createFlameworkCompat` (below) keeps every call site; `createNetwork` is the native API |
76
-
77
- `typetorch migrate --from flamework` (CLI) does most of this, and the docs' `guides/from-flamework.md` has the whole
78
- mapping.
79
-
80
- ## Usage
81
-
82
- ```ts
83
- // src/server/boot.ts
84
- import { startServer, type ServerKernel } from "@typetorch/framework";
85
- import { BUILD } from "../shared/build";
86
- export function boot(kernel: ServerKernel) {
87
- return startServer(kernel, { modules: [script.Parent!.FindFirstChild("services")!], build: BUILD });
88
- }
89
-
90
- // src/shared/net.ts
91
- import { createNetwork, type ProperReturns } from "@typetorch/framework";
92
- interface ClientToServer { coins: { collect(coinId: string): void; balance(): ProperReturns<number> } }
93
- interface ServerToClient { coins: { changed(total: number): void } }
94
- export const network = createNetwork<ClientToServer, ServerToClient>();
95
-
96
- // src/server/services/coin.service.ts
97
- @Service()
98
- export class CoinService extends Module implements OnStart {
99
- constructor(private readonly score: ScoreService) { // injected
100
- super();
101
- }
102
- onStart() {
103
- this.trove.add(network.server.coins.collect.on((player, coinId) => this.score.add(player, 1)));
104
- }
105
- }
106
- ```
107
-
108
- - **Modules:** `@Service()` (server) and `@Controller()` (client) classes extending `Module`. Constructor parameters
109
- are other modules, injected by type. Lifecycle: `OnInit` (sequential, dependencies first), `OnStart` (spawned),
110
- `OnStop` (reverse order), `OnTick`, `OnPhysics`, `OnRender`, `OnPlayerAdded` (replays players already in the server).
111
- - **Modules outside the constructor:** `Dependency<T>()` (or `TypeTorch.module<T>()`) returns the running module of
112
- type T from anywhere: a method, a plain class a module built, a command handler. `TypeTorch.tryModule<T>()` returns
113
- undefined instead of throwing. They work once every module is constructed (from onInit on); in a constructor, a
114
- field initializer or module top-level code they throw `"ShopService isn't constructed yet: ..."`. Only this realm's
115
- modules, and per generation: after a swap they return the new generation's instance. An `import type` of the class is
116
- enough (no require cycle).
117
- - **Cycles:** two modules that need each other take one side as `Lazy<T>`, resolved on first use:
118
- `constructor(private readonly team: Lazy<TeamService>)` (needs `@typetorch/transformer` 0.2.1+) or a field,
119
- `private readonly team = Lazy<TeamService>()` (any transformer). `this.team.get()` works from onInit on (in a
120
- constructor it throws), and the start order ignores lazy edges. A cycle error names the cycle and suggests this.
121
- - **Per-player state:** `this.ctx.playerState("cooldowns.v1", (player) => init)` (or `TypeTorch.playerState`) returns
122
- `{ get, set, has, delete }` keyed by UserId. It survives swaps (persist), and a player's entry goes when they really
123
- leave, never on a swap. The dev menu shows it under Modules > State > persist, `__playerState`.
124
- - **Bad deploys roll back (kernel 0.3.2):** a server `onStart` that throws is reported to the kernel, which rolls the
125
- server back to its last known good artifact when it happens within 30 s of start (so do 3 errors from the new
126
- code's scripts in that time). On a real shutdown every module's `onStop` runs too (the kernel's BindToClose).
127
- - **Troves:** every module gets `this.trove`; everything it creates or connects goes there, so a swap leaves nothing
128
- behind. `TypeTorch.persist(key, init)` (or `this.ctx.persist`) keeps plain data across swaps.
129
- - **Network:** `createNetwork<C2S, S2C>()` with nested namespaces. The server checks rate limits, shape limits and the
130
- generated type guard on every client message. `setNetworkLimits({ "chat.say": { maxString: 200 } })` tunes a leaf.
131
- Requests wait 15 s for the answer: `invokeWithTimeout(5, ...args)` sets it per call, and a leaf's `timeout`
132
- (`setNetworkLimits({ "vc.spawn": { timeout: 30 } })`, read by the client, so set it in shared code) per leaf; both in
133
- seconds, 0.5 to 120. `network.client.x.emit(...args)` runs this client's own `on` handlers for a server -> client
134
- leaf right away, with no traffic (Flamework's `predict`); `network.server.x.emit(player, ...args)` does the same on
135
- the server, for tests. A request handler may return a Promise: the answer waits for it.
136
- - **Flamework networking names:** `createFlameworkCompat<C2SEvents, S2CEvents, C2SFunctions, S2CFunctions>()` returns
137
- `{ ServerEvents, ClientEvents, ServerFunctions, ClientFunctions, GlobalEvents, GlobalFunctions }` with
138
- `@flamework/networking` 1.x's methods (`connect` returning a connection, `fire(player | players)`, `broadcast`,
139
- `except`, `predict`, `setCallback`, `invoke`, `invokeWithTimeout`, calling a leaf directly) on this same network:
140
- same guards, limits and swap rules, so a Flamework game's call sites compile unchanged. Listeners run on their own
141
- thread each, like Flamework's, and belong to the running generation (no trove needed). Not carried over: server ->
142
- client requests, middleware, `Networking.Unreliable`. `typetorch build` notes a game still using it.
143
- Across a swap (0.3.0): what the server sends a player waits until that player's client runs the new generation
144
- (reliable messages are queued, unreliable ones dropped), and on kernel 0.3.2 a request the server can no longer
145
- answer fails at once ("The game is updating, try again.") instead of timing out.
146
- - **Macros:** `Modding` (from this package) declares compile-time macros that `@typetorch/transformer` fills in:
147
- `/** @metadata macro */ export function guardOf<T>(guard?: Modding.Generic<T, "guard">) { return guard!; }` makes
148
- `guardOf<Shape>()` compile to a `t` guard. `Modding.Generic<T, "id" | "text">`, `Many`, `Caller` and `TupleLabels`
149
- work the same way; see the transformer's README.
150
- - **UI:** `observeElement(trove, tag, (instance, elementTrove) => ...)`, `isRealFrame`, `popIn` / `popOut` / `bump`
151
- (UIScale, never Size tweens) and `PopupQueue` (one modal at a time).
152
- - **Dev menu:** devs (Studio, project members, dev badge) get a DEV button, `Ctrl+Shift+D` and `/tt dev`: artifact
153
- (with how long the server and client generations have run, the server's uptime and the build's age), server status,
154
- logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and
155
- build picker (Server > Branch) and a Claude prompt. The sidebar, top to bottom: Artifact, Server, Network, Modules,
156
- Manage, Logs, Dex, Claude. The window can be dragged by its header and resized from its corner (double-tap the
157
- header to reset).
158
- - **Server TPS (framework 0.4.2, kernel 0.4.2):** Server > Status has a *TPS* line next to Memory and Lua heap: the
159
- last minute's average, its slowest second and the physics FPS (`59.9 avg, 52.0 min, physics 60`; the warning colour
160
- under 50 on average). Older kernels show `needs kernel 0.4.2`. The same numbers ride on fleet heartbeats (the
161
- explorer's fleet table).
162
- - **Dev-only tools (framework 0.4.1, `devtools/access.ts`):** Claude, Logs > Upload, Dex edits on the server, Network
163
- packet blocking and the hot-swap sound work under dev rules (private, reserved and Studio servers on a dev branch)
164
- for every dev, and on a public server an owner switched to a dev branch (Server > Branch) for owners only. Everywhere
165
- else the menu is read-only, with a short reason: "Dev branch only", "Owners only on public servers", "Owner-switched
166
- servers only". The rules themselves don't change (`TypeTorch.channel`, signatures, owner-only rollbacks).
167
- - **Panes and windows (dev menu):** every page shows in a pane with a small header: tap the title to pick another
168
- page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them.
169
- The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by
170
- their title bar and resize from their corner (double-tap the title bar to maximise); their title bar also has
171
- Minimise, Dock (back into the main panel) and Close. Windows can be split too. The layout, the windows and each
172
- pane's own settings (for example the open nodes of two Modules > State panes) survive swaps; a fresh join starts
173
- with one pane. Claude, Server > Branch and the Manage pages open once (picking them again focuses their pane); every
174
- other page can be open several times. Panes nobody sees (menu closed, window minimised) stop refreshing, and panes
175
- on the same data share one request (Status, Overview, Assets, Network > Stats, server Logs, Packets); State panes
176
- share one query budget. On phones and small screens panes only stack (two at most), and windows open maximised with
177
- one pane each.
178
- - **State explorer (Modules > State, Server | Client):** every live module of the running generation (services on the
179
- server, controllers on the client) and the persist store. Tap a row to open it: the module's own fields (not its
180
- methods), then tables, arrays, Maps and Sets, 100 entries per page (Prev / Next). Each value has a short preview:
181
- strings (cut), numbers, booleans, nil, datatypes through `tostring`, an Instance as its full path, a Player as its
182
- name, `function`, `thread`, buffer sizes. Nothing is ever called (raw reads only: no metamethods, no getters); a
183
- value that is its own ancestor shows as a cycle. A key filter (open nodes stay listed), Refresh and Auto (every 2 s).
184
- Server state comes from the op `state.inspect` (rate-limited, size-capped): owners everywhere, other devs where the
185
- dev-only tools are open to them, since server state can hold player data.
186
- - **Logs > Upload:** sends the logs shown (Server: this server's log ring; Client: your own client's; Others: the
187
- picked player's client) to the dev PC paired in the Claude tab, which saves them under `<repo>/.typetorch/logs/`.
188
- No Claude run, no prompt quota. Not paired: "Pair in the Claude tab first".
189
- - **Switch a live server (kernel 0.3.4):** owners switch any server and load builds on it, public and prod ones too:
190
- Server > Branch, "Switch" on a branch or "Load here" on a build, then the green Confirm. Everyone stays. These are
191
- ordinary switches and pins: Reload and Rollback keep working, and going back to prod is Switch on the prod row (its
192
- verified head). A public switch lasts for that server's lifetime and is never stored, so new servers still boot the
193
- signed prod head. Others see "Join", which moves only them to a reserved server. Manage > Servers "Load a build..."
194
- does the same for this server, the ticked servers or a share of one branch (A/B pins), with one status line under
195
- the list. A public server an owner switched to a dev branch opens the dev-only tools (Claude too) to owners.
196
- - **Owners and devs:** two roles (no admins: an older kernel's or typetorch.json's "admin" counts as a dev). The Manage
197
- group (Players, Servers, Bans) is for owners only.
198
- - **Remote Claude** (a dev-only tool: dev rules, or owners on a public server an owner switched to a dev branch):
199
- while `typetorch remote-claude` (`@typetorch/dev-server`) runs on a dev's machine, allowlisted devs prompt Claude
200
- Code from the Claude tab. Each dev pairs once by pasting the pairing code printed by
201
- typetorch-dev-server; the game server keeps the session URL and the tokens in memory and never sends them to clients.
202
- No Roblox secret is needed.
203
-
204
- ## Runtime API: `TypeTorch`
205
-
206
- `import { TypeTorch } from "@typetorch/framework"` works on the server and the client. It describes the running
207
- generation and raises events that belong to it: every listener is dropped when the generation stops, so a swap never
208
- leaves one behind. Each `on*` returns a disconnect function, which a trove can own.
209
-
210
- ```ts
211
- // Identity
212
- TypeTorch.artifact; // { id, commit, commitHash, branch, channel, builtAt, seq, assetId }
213
- TypeTorch.generation; // 1, 2, 3... per server (or client)
214
- TypeTorch.branch; TypeTorch.channel; TypeTorch.serverType; // "public" | "private" | "reserved" | "studio"
215
- // Kernel 0.3.9: `channel` is the RULES this server runs under ("prod" on every public server; split data stores by it);
216
- // `branchChannel` is what the branch IS ("prod" for the default branch or one configured prod, else "dev");
217
- // `rules` is the same as `channel`, by name.
218
- TypeTorch.branchChannel; TypeTorch.rules;
219
- TypeTorch.isPinned(); TypeTorch.kernelVersion; TypeTorch.kernelApi; TypeTorch.jobId; TypeTorch.isStudio;
220
-
221
- // How this generation started: a branch change starts a new generation.
222
- const start = TypeTorch.startInfo; // { kind: "boot" | "swap", reason, previous?, branchChanged, requestedBy?, loadSeconds? }
223
- if (start.reason === "auto_rollback") warn("the last deploy failed to start");
224
- this.trove.add(TypeTorch.onBranchChanged(({ from, to }) => resetBranchData(from, to)));
225
-
226
- // Before this generation stops (runs before every onStop; keep it short)
227
- this.trove.add(TypeTorch.onSwapOut((info) => this.saveRound(info.reason)));
228
-
229
- // A swap is coming: show a small hint, hide it if it's called off
230
- this.trove.add(TypeTorch.onUpdatePending((update) => (hint.Visible = !update.cancelled)));
231
-
232
- // State that survives swaps (plain data only)
233
- const scores = TypeTorch.persist("scores.v1", () => new Map<number, number>());
234
- // Per player, removed when the player really leaves (same as this.ctx.playerState)
235
- const cooldowns = TypeTorch.playerState("cooldowns.v1", () => ({ lastUse: 0 }));
236
- cooldowns.get(player).lastUse = os.clock();
237
-
238
- // The running module of a type (same as Dependency<T>()), from onInit/onStart on
239
- TypeTorch.module<ShopService>().open(player);
240
- TypeTorch.tryModule<ShopService>()?.open(player); // undefined instead of an error
241
-
242
- // Dev and roles (server: the kernel decides; client: only the local player, cosmetic)
243
- if (TypeTorch.isOwner(player)) showOwnerPanel(player);
244
- TypeTorch.onPlayerDevChanged((player, info) => setDevTools(player, info.dev));
245
-
246
- // Server only
247
- TypeTorch.status(); // uptime, players, memory, history: cheap
248
- TypeTorch.branches(); TypeTorch.artifacts(); // registry reads, cached ~30 s: may yield, don't call per frame
249
- TypeTorch.requestReload(player); // owners only, checked by the kernel
250
-
251
- // Logs (the kernel's ring buffer)
252
- TypeTorch.logs(0, 50);
253
- TypeTorch.onLog((entry) => errors.push(entry)); // don't print from inside it
254
-
255
- // Hot assets (below): same as hotAsset(...)
256
- const shop = TypeTorch.asset("ui/shop");
257
-
258
- // Live values, server only (kernel 0.3.8): the signed settings record's `game` field (`typetorch settings set game.<key> <json>`).
259
- const price = TypeTorch.liveConfig("shop.price", { default: 50, parse: (raw) => (typeIs(raw, "number") ? raw : 50) });
260
- price.get();
261
- this.trove.add(price.onChanged((value) => this.reprice(value)));
262
-
263
- // Cross-server, server only. Kernel 0.3.8: game topics over ONE kernel-held MessagingService topic; listeners hear
264
- // prod servers and this branch by default (dev branches never reach prod); publish queues and retries, max 1 KiB.
265
- this.trove.add(TypeTorch.messaging.subscribe<Ban>("1guard", (ban, meta) => applyBan(ban, meta.jobId)));
266
- TypeTorch.messaging.publish("1guard", { kind: "ban", userId }, { to: "prod" });
267
-
268
- // The universe's live servers (the roll call; cached per server, yields up to ~3 s when stale) and this server's public fields
269
- TypeTorch.setServerInfo({ region: "eu", vc: true }); // JSON, at most 400 bytes, kept across swaps
270
- const list = TypeTorch.servers(); // [{ jobId, placeVersion, players, maxPlayers, serverType, branch, channel, uptime, here, info }]
271
-
272
- // A library call a swap must not cut off halfway (kernel 0.3.8): runs on a kernel thread; write what must survive into persist.
273
- TypeTorch.runDetached(() => store.StartSessionAsync(`Player_${userId}`)).then((profile) => attach(player, profile));
274
- ```
275
-
276
- - **`startInfo.reason`:** `boot`, `deploy`, `rollback`, `branch`, `pin`, `reload`, `server_rollback`,
277
- `auto_rollback` or `unknown`. A client's first generation is always `boot`. `requestedBy` (a user id), `loadSeconds`,
278
- `stopSeconds` and `swapSeconds` are server-only; `swapSeconds` appears once the swap has finished.
279
- - **`persist(key, init)`** keeps a table in the kernel's memory for the server's lifetime; every later generation gets
280
- the same table back. Store plain data only: tables, arrays, Maps and Sets of strings, numbers, booleans, and
281
- Players. Never store functions, class instances, Promises, threads, connections, charm atoms or instances this
282
- generation created: they keep the old generation's code alive or are destroyed by its trove. Version the key when
283
- the shape changes. Keys starting with `__` are reserved.
284
- - **`onSwapOut`** runs synchronously before every module's `onStop`, so modules can still save into `persist`. It
285
- doesn't run on server shutdown (kernel 0.3.2 runs `onStop` then; on older kernels use `game.BindToClose`).
286
- - **Kernel versions:** `TypeTorch.features` says what the running kernel supports. Kernel 0.2.2 adds the start
287
- reason and timings, the next artifact in `onSwapOut`, `onUpdatePending`, server-side `onPlayerDevChanged` and
288
- `requestReload`. On older kernels `startInfo` still knows boot vs swap, the previous artifact and branch (the
289
- framework records them), `onBranchChanged` still fires, and the reason of a swap is `unknown`.
290
- - **`messaging`** (kernel 0.3.8): `subscribe(topic, (data, meta) => ...)` returns a disconnect; `meta` = `{ channel,
291
- branch, jobId, serverType, placeVersion, sentAt, self, replayed? }`; `{ from: "all" | "prod" | "branch" }` changes
292
- who it hears. `publish(topic, data, { to })` throws on a bad topic, non-JSON data or a message over 1 KiB (with its
293
- size), returns false when dropped at once (queue full). Studio loops back; the cloud test publishes nothing; older
294
- kernels throw "needs kernel 0.3.8". `servers()` works on every kernel (0.3.8 holds the roll call topic, so a swap
295
- doesn't subscribe again).
296
- - **`liveConfig(key, { default, parse? })`** (kernel 0.3.8, plans/20): reads `game[key]` of the signed settings record the
297
- kernel verified (signed by both prod keys, so game code can't forge it); `get()` and `onChanged(fn)` (only on real
298
- changes; generation-scoped). The default without a record or key, when `parse` throws (warned per value), and on older
299
- kernels (warned once: "needs kernel 0.3.8"; no ConfigService fallback). `TypeTorch.settings()` is the whole copy
300
- (`seq`, `at`, `game`, `analytics`, `fleet`, ...): server only, it holds tokens.
301
- - **`runDetached(fn)`** (kernel 0.3.8, server only): runs `fn` on a thread the kernel owns, so a deploy's hard stop
302
- (which kills this generation's threads mid-call) can't cut it off: for ProfileStore / ProfileService loads, saves and
303
- releases. Returns a Promise that settles while this generation runs; after a swap the result is dropped, so a job
304
- whose result must survive writes it into `persist` itself. The job keeps this generation's closures alive until it
305
- ends. Errors reject it and go to the kernel's log with the generation's name (never the health window); at most 256
306
- run at once per server (then it throws); one past 60 s is logged and flagged in Server > Status. Older kernels
307
- throw "needs kernel 0.3.8; use the DataHost job queue (Player data guide)": the other option, which works on every
308
- kernel. `features.runDetached` tells which one this server has.
309
- - **Edit mode** (UI Labs stories, no kernel): `running` is false, identity has defaults, `persist` keeps a local table,
310
- events never fire, and the server-only reads throw.
311
-
312
- ## Player data
313
-
314
- Saving player data is game code: pick any library (ProfileStore, DataStore2, your own). TypeTorch only gives you the
315
- pieces that keep it safe across swaps:
316
-
317
- - **The library lives in the place**, outside the payload, and a small place Script requires it first. Its open
318
- sessions, autosave loop and shutdown hook then survive every swap (a swap stops the generation's scripts).
319
- - **Session handles live in `persist`**, keyed by `UserId`. `onPlayerAdded` replays everyone after a swap, so it
320
- re-attaches to the open session instead of loading again.
321
- - **Release only on `Players.PlayerRemoving`**, never in `onStop` or `onSwapOut`.
322
- - **Library calls can't be cut off by a swap:** run them as DataHost jobs (any kernel) or with
323
- `TypeTorch.runDetached` (kernel 0.3.8).
324
- - **Split store names by channel** (`TypeTorch.channel === "prod" ? "PlayerData" : "PlayerData_dev"`).
325
-
326
- Full example with ProfileStore: [Player data guide](https://github.com/typetorch/docs/blob/main/guides/player-data.md).
327
-
328
- ## Hot assets: `hotAsset`
329
-
330
- Builders mark models and UI templates in the place with the attribute `TypeTorchAsset` (a key such as `"ui/shop"`),
331
- `typetorch assets sync` uploads them, and the deploy puts the asset manifest in the artifact. Running servers then get
332
- new versions live, with no restart.
333
-
334
- ```ts
335
- import { hotAsset } from "@typetorch/framework"; // or TypeTorch.asset("ui/shop")
336
-
337
- // In a controller's onStart: rebuild clones the template.
338
- const shop = hotAsset("ui/shop");
339
- shop.changed(rebuild, this.trove); // a new version went live
340
- rebuild(shop.get());
341
- ```
342
-
343
- - **`hotAsset(keyOrId, fallback?)`** works on the server and the client. A number is the Roblox asset id, resolved
344
- through the manifest (server) or the live copy's `TypeTorchAssetId` attribute (client).
345
- - `get()` returns the live copy now. With none, it returns `fallback` (an instance you already hold, such as the
346
- template in the place) if it is still parented, else `undefined`. **Clone it; don't parent or edit it:** a new
347
- version destroys it.
348
- - `wait(timeout?)` is `get()` that waits for a live copy (forever without a timeout).
349
- - `changed(fn, trove?)` calls `fn(instance)` each time a new copy replaces the live one: a new version, a rollback,
350
- or the first copy reaching a client. It returns a disconnect function. Pass the module's trove so the connection
351
- ends with it (`shop.changed(rebuild, this.trove)` or `this.trove.add(shop.changed(rebuild))`). Without a trove it
352
- ends when the generation stops.
353
- - `key` and `version` (the live copy's `TypeTorchAssetVersion`, an assetVersionId).
354
- - **Clients never request anything.** They read the CollectionService tag `__typetorch_asset:<key>`, and replicated
355
- assets arrive through replication. Assets under ServerStorage stay server-only.
356
- - **AssetSync** is a server built-in. It runs on every generation start, before any module loads (top-level code
357
- included). For each manifest key:
358
- 1. it keeps the live copy that already has the manifest's version;
359
- 2. else it adopts the place's own copy, if its `TypeTorchAssetHash` matches (or if the manifest's optional
360
- `placeVersion` is this server's place version);
361
- 3. else it runs `InsertService:LoadAssetVersion(ver)` (all loads in parallel), strips any scripts, places the copy
362
- at its path (creating missing Folders), tags it, then destroys the copy it replaces.
363
-
364
- It holds the start for at most 8 s. Loads still running after that keep going, swap in when done and fire
365
- `changed`. A failed load keeps the old copy (on a new server, the place's copy) and shows under Server > Status >
366
- Attention.
367
- - **Hot assets persist across swaps.** They aren't in any trove. A swap changes them only when the manifest changes,
368
- and a rollback brings back the older versions. **Keys the manifest doesn't name are left as they are:** AssetSync
369
- never deletes builders' content. A key dropped from the manifest keeps its last live copy.
370
- - **Clones never count.** A clone keeps the tag and the attributes, but only the copy whose parent is its
371
- `TypeTorchAssetPath` (stamped by AssetSync) is the live one. So a template cloned into PlayerGui never shows up in
372
- `get()`, and AssetSync never destroys it.
373
- - **Dev menu:** Modules > Assets (Server | Client) lists each key's source (kept, baked, loaded, failed), version,
374
- load time and last error.
375
-
376
- ## Analytics: `AnalyticsEngine`
377
-
378
- Optional, and all ours: the game server sends rows to the backend the dev picked (Cloudflare Basin streams, or a
379
- self-hosted DuckDB analytics server). Nothing runs until an engine is created: no connections, threads or requests.
380
-
381
- ```ts
382
- import { AnalyticsEngine } from "@typetorch/framework";
383
-
384
- // A server module (onInit): the sink comes from the signed settings' `analytics` (kernel 0.3.8).
385
- const analytics = new AnalyticsEngine();
386
- analytics.track(player, "quest_done", { quest: "tutorial" }); // custom
387
- analytics.step(player, "onboarding", 3, "opened_shop"); // funnels
388
- analytics.purchase(player, { product: 1234, robux: 99, where: "shop" }); // after the receipt is granted
389
- analytics.currency(player, "coins", 50, "round_reward"); // economy in (+) and out (-)
390
- analytics.state("round"); // every player's activity; analytics.state(player, "shop") for one
391
- const variant = analytics.experiment(player, "onboarding", ["short", "long"]); // first = control
392
-
393
- // A client controller (onStart): everything goes through the server, never to the internet.
394
- const analytics = new AnalyticsEngine();
395
- analytics.track("opened_map");
396
- analytics.screen("Inventory"); // for UIs that aren't separate ScreenGuis
397
- const variant = analytics.experiment("onboarding", ["short", "long"]); // same answer as the server's
398
- ```
399
-
400
- - **One engine per generation and realm.** Every `new AnalyticsEngine()` joins the one already running (the first one's
401
- options win), so any module can create its own. It stops with the generation; its unsent rows wait in `persist`
402
- and the next generation sends them. In edit mode (UI Labs) it is inert.
403
- - **Server calls take the player first** (`track(player, ...)`); without one an event is server-only (no player id).
404
- Client calls are always about the local player and are marked `src = "client"` (a client can lie). The server
405
- checks their shape, size (props at most 4 KB) and rate. `purchase` and `currency` are server-only (revenue and the
406
- economy are server-authoritative): on the client they warn once and send nothing, and the server refuses them from
407
- clients.
408
- - **Options** (all on by default): `sessions` (joins, leaves, device, join source, account age bucket, Premium,
409
- country, friends in the server, first-ever vs returning, days since the last visit), `tech` (FPS, ping, memory, load
410
- time, client and server errors, swaps and rollbacks, leaves within 60 s of a swap), `zones` (parts or models tagged
411
- `TTZone`, named by a `Name` attribute or the instance name), `screens` (ScreenGuis in PlayerGui, GuiObjects tagged
412
- `TTScreen`), `recording` (the first-ever session in detail), `fleet` (kernel 0.3.2 heartbeats and deploy reports),
413
- `identity` (one `{ pid, uid }` row per session to the dev's own server, below). `settings` (server only) replaces the
414
- signed settings' `analytics`, for tests, Studio and kernels before 0.3.8.
415
- - **Every row** carries the time (server clock), a random player id (never the UserId), the session, the server
416
- (JobId, type, place), the artifact (id, seq, branch, channel), the device, new vs returning, the player's state
417
- (`zone:Lobby|screen:Shop|activity:round`) and experiment variants. The exact rows: `src/analytics/SCHEMA.md`.
418
- - **Experiments:** `experiment(...)` is deterministic per player and name (they keep their variant in every session),
419
- may yield briefly the first time (until the player's id loads), and stamps the variant on the player's later events.
420
- The settings turn one off (`active: false`: everyone gets the first variant), sets `weights` or forces a
421
- `variant`, live.
422
- - **First-ever session:** for new players (a share of them, `recordShare`), the client records character and camera
423
- about 10 times a second, every input (never while a TextBox or the chat has focus; text boxes only say which box was
424
- used), buttons pressed and hovered, screens, prompts and deaths, from the join until 60 s after the first input.
425
- Packed into small binary chunks (10-20 KB a player) and sent through the server.
426
- - **Never collected:** chat or anything typed, usernames and display names (error texts have them replaced). No event
427
- row carries a UserId.
428
- - **Identities** (option `identity`, default on): once the pid is known, one row `{ pid, uid, t }` (the UserId, nothing
429
- else) per session goes to the dev's own server only, never into the events: duckdb games in the batch body, Basin
430
- games to the backend (the record's `backend` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
431
- analytics server keeps pid -> UserId in a deletable table (support lookups, Right to Erasure).
432
- - **Player ids:** DataStore `TypeTorchAnalytics`, key `p/<UserId>` -> `{ pid, first, last }`: one read per join, a
433
- write on the first join and at leave. Deleting the key (Right to Erasure) leaves that player's rows anonymous.
434
- - **Sending:** one queue on the server (10,000 events; over that the oldest are dropped and counted), a flush every
435
- `flushSeconds` (15) or at 500 rows, at most ~10 HttpService requests a minute, retries with backoff, and a last
436
- flush on shutdown (kernel 0.3.2). Delivery is at least once (a swap mid-request sends that batch again).
437
- `analytics.stats()` (server) has the counters; `flush()` sends soon.
438
- - **When uploads fail** (a stale quick-tunnel URL, the dev PC's server or tunnel down, a wrong token, HttpService off;
439
- Roblox reports most network problems as `HttpError: NetFail`, `DnsResolve` or `ConnectFail`): the server backs off
440
- with jitter (5, 10, 20 ... 300 s, spread +-25% so a fleet of servers doesn't retry in step; a wrong token or URL waits
441
- about 5 minutes, and a settings change retries at once), keeps the rows, and logs at most ONE line a minute: the
442
- reason and the fix (`analytics upload failed 5 times in a row: NetFail: the connection broke mid-request. ... Retrying
443
- in 80 s.`, with "(+N more in the last minute)" for the lines it held back) and one line when uploads work again.
444
- `stats()` also has `failures` (in a row), `failed` (since the server started), `lastError`, `lastStatus`
445
- (0 = no answer), `lastErrorAt` / `lastOkAt` (`os.time()`), `retryIn`, and, while failing, `reason` and `fix`. The dev
446
- menu shows them: **Server > Status** has an *Analytics* line (queued, sent, `FAILING x5: ... retry in 80 s`) and a
447
- *Fleet API* line (the kernel's sender: sent, failed, last error and its age), and **Attention** lists the reason and
448
- the fix; `typetorch doctor` tests the address and token in the settings record.
449
- - **The cloud test sends nothing.** `typetorch test --cloud` boots the payload headless in a Luau Execution task where
450
- HttpService works; there the engine (stub kernel `test = true`, or `workspace:GetAttribute("TypeTorchTest")`) collects
451
- as usual but never uploads: no event rows, no identity rows, no HTTP request, so a prod deploy never puts a fake
452
- server session into your analytics.
453
- - **Bounds on what a client can cause:** at most 32 experiments per session (each is stamped on every later row), and
454
- a client's `exp` messages count against its event budget (120 a minute, 5,000 a session). Clients can't send
455
- `purchase` or `currency` rows; the analytics server's ingest also refuses client-marked ones (older engines) and its
456
- revenue queries count `purchase` rows the server sent.
457
- - **Friends in the server** (the join's `friends`): one `Players:GetFriendsAsync` per join (at most 10 pages, none
458
- when the player is alone) intersected with the players there, not a web call per player.
459
-
460
- **Settings, framework 0.4.0 / kernel 0.4.0 (plans/21):** the record's `backend` = `{ url, key, analytics?: { flushSeconds,
461
- recordShare } }` (`typetorch backend setup`) is the whole sink: DuckDB at `<url>/v1/ingest` with the one key (Basin
462
- identities to `<url>/v1/identity`). The old `analytics` value below is read while `backend` is missing, and stays the
463
- way to a Basin sink. The engine also tells the kernel each player's pid (`setAnalyticsId`: the kernel's error reports
464
- count affected players by pid, never by name) and counts its own DataStore and HTTP requests for the budget view (dev
465
- menu **Server > Budget**: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
466
-
467
- **Settings** (server only, never sent to clients): the signed settings record's `analytics` field (kernel 0.3.8,
468
- plans/20), derived from `backend` by `typetorch backend setup` (`writeBackendSettings()` in `@typetorch/backend` runs it, and
469
- `bun run local` too). Every new copy applies live within seconds (the CLI pings servers). A Basin sink has no CLI writer
470
- since CLI 0.9: `backend setup` replaces it. Before kernel 0.3.8
471
- there are no settings unless the game passes `new AnalyticsEngine({ settings })`:
472
-
473
- ```json
474
- { "backend": "basin", "events": "https://<stream-id>.ingest.cloudflare.com",
475
- "recordings": "https://<stream-id>.ingest.cloudflare.com", "token": "<write-only token>",
476
- "flushSeconds": 15, "recordShare": 1, "techEvery": 60,
477
- "experiments": { "onboarding": { "weights": [1, 1] } } }
478
- ```
479
-
480
- - `basin`: a JSON array per stream (`events`, `recordings`), `Authorization: Bearer <token>` when set (a token with
481
- Basin Pipelines Send permission, if the stream requires authentication). Without `recordings` nothing is recorded.
482
- The streams' schemas must match SCHEMA.md exactly: Basin drops rows that don't, silently.
483
- - `duckdb`: one `POST <events>` with `{"events":[...],"recordings":[...]}`, gzip, `Authorization: Bearer <token>`.
484
- - The token is write-only and never logged. Needs **Allow HTTP Requests** (Game Settings > Security). No key: the
485
- engine collects but keeps only the newest 1,000 rows until settings appear. Removing the key stops sending, live.
486
-
487
- ## Develop
488
-
489
- ```sh
490
- bun install
491
- bun run build # rbxtsc --type package -> out/
492
- ```
493
-
494
- - **Runtime of the transformer:** `src/reflection/` (`Reflect`, `Modding`, `t`) is a copy of
495
- `@typetorch/transformer`'s `runtime-kit/`; keep the two identical.
496
- - **Tests (Lune, offline):** `scripts/test-*.luau`; each file's header has its command (Lune is pinned in the
497
- kernel's and the template's `rokit.toml`). `scripts/test-generations.luau` takes a game's built payload:
498
- `cd ../template && bun run payload && lune run ../framework/scripts/test-generations.luau build/payload.rbxm` boots
499
- two generations in one VM and checks fresh registries, DI and generated guards, then runs the real `startServer`
500
- with stub kernels (onStart failures reported to kernel 0.3.2, raised on older ones; onClose), the health lines and
501
- the Branch tab / "Load a build" rules (`devtools/build-actions.ts`).
502
- To test framework changes before the template takes them, build the payload from a copy of the template whose
503
- `node_modules/@typetorch/framework/out` is this repo's `out/`.
504
- `scripts/test-state-inspect.luau` checks the state explorer's core (previews, raw reads only, cycles, paging,
505
- Maps/Sets with Instance keys, the depth limit, filters, request validation) and that `src/version.ts` matches
506
- package.json.
507
- `scripts/test-layout.luau` checks the dev menu's pane layout core (`devtools/layout.ts`: splits and their limits,
508
- closing, pop out and dock, singleton pages, ratios, focus, and cleaning a remembered layout).
509
- `scripts/test-modules.luau` runs the real `startServer`/`startClient` of `out/` (with RuntimeLib, Promise, trove and
510
- t from node_modules) over several generations that share a persist store: `Dependency<T>()`, `TypeTorch.module` /
511
- `tryModule` and their errors, `Lazy<T>` (parameters and fields, cycles, start order), network `emit` and request
512
- timeouts and `playerState` (swaps, leaves).
513
- `scripts/test-flamework-compat.luau` (same harness) drives `createFlameworkCompat` through each generation's
514
- dispatcher: connect (one thread per call, guards, Disconnect, troves), fire / except / broadcast / the call
515
- shorthand, setCallback with values and Promises, predict, a swap, and the client side (invoke, invokeWithTimeout,
516
- `createClient({ defaultTimeout })`).
517
- `scripts/test-analytics.luau` checks the analytics engine's pure parts (experiment assignment, settings, the queue
518
- and HTTP budget, tt-rec-1, the sink request bodies, the failure help); `test-analytics-server.luau` and
519
- `test-analytics-client.luau` run the compiled server and client cores against mocked services (sessions, intake,
520
- retries, a NetFail streak and its one log line a minute, a swap with a request in flight, shutdown; the recorder,
521
- screens, batching). `scripts/test-net-health.luau` checks the dev menu's Fleet API / Analytics status lines and
522
- Attention issues.
523
- - **Publishing:** `npm publish` runs `prepublishOnly` (clean + build). The package ships only `out/` (no
524
- `.tsbuildinfo`), `README.md` and `LICENSE`; check with `bun pm pack --dry-run`.
525
-
526
- - **Explorer class icons:** `assets/class-icons.png` (kept locally, not in the repo: it is Roblox's own texture) is
527
- the client's `content/textures/ClassImages.PNG` (a 2352x16 strip) repacked into a 32-column grid, because live
528
- clients downscale textures wider than 1024 px:
529
- `ffmpeg -i ClassImages.PNG -vf "untile=147x1,tile=32x5:color=0x00000000" class-icons.png`. It is uploaded as image
530
- `rbxassetid://97389585475400`; `scripts/gen-explorer-icons.ts` generates the index table.
531
-
532
- MIT licensed.
1
+ <img src="https://raw.githubusercontent.com/typetorch/docs/main/brand/typetorch.svg" width="64" alt="TypeTorch logo">
2
+
3
+ # @typetorch/framework
4
+
5
+ The roblox-ts framework of [TypeTorch](https://github.com/typetorch): hot-swappable modules (dependency injection,
6
+ lifecycle, troves), guarded networking over the kernel's stable remotes, UI helpers and the in-game dev menu.
7
+
8
+ It ships **inside every artifact**, so each generation runs its own fresh copy and framework fixes hot-swap like game
9
+ code. It needs the TypeTorch kernel in the place (`@typetorch/kernel`), which calls your `Server/boot` and
10
+ `Client/boot` modules.
11
+
12
+ ### How it fits with the other TypeTorch packages
13
+
14
+ | Package | Role | Where it runs |
15
+ |---|---|---|
16
+ | `@typetorch/kernel` | Baked into the place. Picks the branch, loads the payload, calls `boot(kernel)`, swaps generations, owns the stable remotes, `persist`, roles, settings and the health window. | the place (server and client) |
17
+ | `@typetorch/framework` (this) | Modules, DI, lifecycle, troves, network, hot assets, analytics and the dev menu, on top of the kernel API (`src/kernel.ts` types it). | inside every payload |
18
+ | `@typetorch/transformer` | roblox-ts compiler plugin: constructor dependency ids, network guards, `Dependency<T>()` / `Lazy<T>` / `TypeTorch.module<T>()` ids and user macros. Its `runtime-kit/` is copied here as `src/reflection/`. | your compile (Node) |
19
+ | `@typetorch/cli` (`typetorch`, alias `tt`) | Builds the payload (`typetorch build`), deploys and hot-swaps it, rolls back, syncs hot assets, writes the signed settings (`liveConfig`, analytics, experiments), migrates Flamework games. | your machine / CI |
20
+ | `@typetorch/dev-server` | `remote-claude`: the dev menu's Claude tab and Logs > Upload talk to it. | a dev's machine |
21
+
22
+ The framework never talks to the CLI directly: the CLI builds and signs payloads and settings, the kernel loads them,
23
+ and the framework reads what the kernel hands it. Features that need a newer kernel check `TypeTorch.features` and say
24
+ "needs kernel x.y.z" on older ones; the dev menu flags kernels older than `LATEST_KERNEL` (`src/devtools/health.ts`,
25
+ now 0.5.3) with a Migrate issue.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ npm i @typetorch/framework
31
+ npm i -D @typetorch/transformer
32
+ ```
33
+
34
+ (`bun add @typetorch/framework` and `bun add -d @typetorch/transformer` work the same.) The framework is a roblox-ts 3
35
+ package; `@typetorch/transformer` is its compiler plugin: it generates the network guards, the constructor dependency
36
+ ids of `@Service` / `@Controller` and your own macros. Nothing from Flamework is needed: `Modding`, `Reflect` and `t`
37
+ come from this package. The starter game ([template](https://github.com/typetorch/template)) has it all set up: clone
38
+ it and run `bun install`.
39
+
40
+ `tsconfig.json`:
41
+
42
+ ```jsonc
43
+ {
44
+ "compilerOptions": {
45
+ "experimentalDecorators": true,
46
+ // Every npm scope the game imports from must be a type root (roblox-ts rule).
47
+ "typeRoots": ["node_modules/@rbxts", "node_modules/@typetorch"],
48
+ "types": ["types", "compiler-types"],
49
+ "plugins": [
50
+ // Optional, first when present: $print/$warn with file and line (what every TypeTorch repo uses).
51
+ { "transform": "rbxts-transform-debug", "environmentRequires": {} },
52
+ { "transform": "@typetorch/transformer" }
53
+ ]
54
+ }
55
+ }
56
+ ```
57
+
58
+ The payload is a Rojo `Model` project. Map only this package from the `@typetorch` scope (the transformer is Node code
59
+ and the kernel lives in the place):
60
+
61
+ ```json
62
+ "include": {
63
+ "$path": "include",
64
+ "node_modules": {
65
+ "$className": "Folder",
66
+ "@rbxts": { "$path": "node_modules/@rbxts" },
67
+ "@typetorch": { "$className": "Folder", "framework": { "$path": "node_modules/@typetorch/framework" } }
68
+ }
69
+ }
70
+ ```
71
+
72
+ Each generation requires its own copy of the payload, so the framework and its `Reflect` registry start fresh on every
73
+ swap. It needs the TypeTorch kernel in the place (`@typetorch/kernel`).
74
+
75
+ ### Coming from Flamework
76
+
77
+ Game code only changes imports: `@Service`, `@Controller`, constructor injection, `Dependency<T>()`, the lifecycle
78
+ interfaces and `createNetwork` are the same.
79
+
80
+ | Flamework | TypeTorch |
81
+ |---|---|
82
+ | `rbxts-transformer-flamework` plugin, `node_modules/@flamework` type root | `@typetorch/transformer` plugin, `node_modules/@typetorch` type root |
83
+ | `import { Modding, Reflect } from "@flamework/core"` | `import { Modding, Reflect } from "@typetorch/framework"` |
84
+ | `import { Dependency } from "@flamework/core"` | `import { Dependency } from "@typetorch/framework"` (from onInit/onStart on, not in constructors) |
85
+ | `import type` + `Dependency<T>()` to break a cycle | the same, or `Lazy<T>` (below) |
86
+ | `ClientEvents.x.predict(...)` | `network.client.x.emit(...)` |
87
+ | `invokeWithTimeout(timeout, ...)` | `invokeWithTimeout(seconds, ...)` (seconds, 0.5 to 120) |
88
+ | `Observers.observeCharacter` / `observeLocalCharacter` (@rbxts/observers) | still fine: put the stop function in the trove, `this.trove.add(observeCharacter(...))` |
89
+ | `import { t } from "@rbxts/t"` (still fine) | also `import { t } from "@typetorch/framework"` |
90
+ | `@metadata flamework:parameters` keys, `"flamework:parameters"` metadata | `@metadata typetorch:parameters`, `"typetorch:parameters"` |
91
+ | `flamework.build`, `include/flamework`, the `@flamework` Rojo mapping | gone; delete them |
92
+ | `Networking.createEvent` / `createFunction` and their call sites | `createFlameworkCompat` (below) keeps every call site; `createNetwork` is the native API |
93
+
94
+ `typetorch migrate --from flamework` (CLI) does most of this, and the docs' `guides/from-flamework.md` has the whole
95
+ mapping.
96
+
97
+ ## Usage
98
+
99
+ ```ts
100
+ // src/server/boot.ts
101
+ import { startServer, type ServerKernel } from "@typetorch/framework";
102
+ import { BUILD } from "../shared/build"; // written by `typetorch build`
103
+ export function boot(kernel: ServerKernel) {
104
+ return startServer(kernel, { modules: [script.Parent!.FindFirstChild("services")!], build: BUILD });
105
+ }
106
+
107
+ // src/shared/net.ts
108
+ import { createNetwork, type ProperReturns } from "@typetorch/framework";
109
+ interface ClientToServer { coins: { collect(coinId: string): void; balance(): ProperReturns<number> } }
110
+ interface ServerToClient { coins: { changed(total: number): void } }
111
+ export const network = createNetwork<ClientToServer, ServerToClient>();
112
+
113
+ // src/server/services/coin.service.ts
114
+ import { Module, Service, type OnStart } from "@typetorch/framework";
115
+ import { network } from "../../shared/net";
116
+ import { ScoreService } from "./score.service";
117
+
118
+ @Service()
119
+ export class CoinService extends Module implements OnStart {
120
+ constructor(private readonly score: ScoreService) { // injected
121
+ super();
122
+ }
123
+ onStart() {
124
+ this.trove.add(network.server.coins.collect.on((player, coinId) => this.score.add(player, 1)));
125
+ this.trove.add(network.server.coins.balance.handle((player) => [this.score.get(player)]));
126
+ }
127
+ }
128
+ ```
129
+
130
+ `startClient(kernel, options)` is the same for `src/client/boot.ts` with your controllers folder. `StartOptions`:
131
+ `modules` (folders whose ModuleScripts are required, recursively, so their decorators register), `build` (the
132
+ generated build info, shown in the dev menu) and `devtools` (default `true`; `false` leaves the dev menu out). Both
133
+ return the generation's stop function, which the kernel calls on a swap.
134
+
135
+ - **Modules:** `@Service()` (server) and `@Controller()` (client) classes extending `Module`. Constructor parameters
136
+ are other modules, injected by type. Lifecycle: `OnInit` (sequential, dependencies first), `OnStart` (spawned),
137
+ `OnStop` (reverse order), `OnTick`, `OnPhysics`, `OnRender`, `OnPlayerAdded` (replays players already in the server).
138
+ `@Service({ loadOrder: -1 })` breaks ties between modules with no dependency relation (lower first, default 0).
139
+ `this.ctx` has the realm, artifact, branch, channel, generation, build info, the raw kernel, `persist` and
140
+ `playerState`. `observePlayers(trove, (player, playerTrove) => ...)` is `OnPlayerAdded` for code outside a module.
141
+ - **Modules outside the constructor:** `Dependency<T>()` (or `TypeTorch.module<T>()`) returns the running module of
142
+ type T from anywhere: a method, a plain class a module built, a command handler. `TypeTorch.tryModule<T>()` returns
143
+ undefined instead of throwing. They work once every module is constructed (from onInit on); in a constructor, a
144
+ field initializer or module top-level code they throw `"ShopService isn't constructed yet: ..."`. Only this realm's
145
+ modules, and per generation: after a swap they return the new generation's instance. An `import type` of the class is
146
+ enough (no require cycle).
147
+ - **Cycles:** two modules that need each other take one side as `Lazy<T>`, resolved on first use:
148
+ `constructor(private readonly team: Lazy<TeamService>)` (needs `@typetorch/transformer` 0.2.1+) or a field,
149
+ `private readonly team = Lazy<TeamService>()` (any transformer). `this.team.get()` works from onInit on (in a
150
+ constructor it throws), and the start order ignores lazy edges. A cycle error names the cycle and suggests this.
151
+ - **Per-player state:** `this.ctx.playerState("cooldowns.v1", (player) => init)` (or `TypeTorch.playerState`) returns
152
+ `{ get, set, has, delete }` keyed by UserId. It survives swaps (persist), and a player's entry goes when they really
153
+ leave, never on a swap. The dev menu shows it under Modules > State > persist, `__playerState`.
154
+ - **Bad deploys roll back (kernel 0.3.2):** a server `onStart` that throws is reported to the kernel, which rolls the
155
+ server back to its last known good artifact when it happens within 30 s of start (so do 3 errors from the new
156
+ code's scripts in that time). On a real shutdown every module's `onStop` runs too (the kernel's BindToClose).
157
+ - **Troves:** every module gets `this.trove`; everything it creates or connects goes there, so a swap leaves nothing
158
+ behind. `TypeTorch.persist(key, init)` (or `this.ctx.persist`) keeps plain data across swaps.
159
+ - **Network:** `createNetwork<C2S, S2C>()` with nested namespaces. The server checks rate limits, shape limits and the
160
+ generated type guard on every client message. `setNetworkLimits({ "chat.say": { maxString: 200 } })` tunes a leaf.
161
+ Requests wait 15 s for the answer: `invokeWithTimeout(5, ...args)` sets it per call, and a leaf's `timeout`
162
+ (`setNetworkLimits({ "vc.spawn": { timeout: 30 } })`, read by the client, so set it in shared code) per leaf; both in
163
+ seconds, 0.5 to 120. `network.client.x.emit(...args)` runs this client's own `on` handlers for a server -> client
164
+ leaf right away, with no traffic (Flamework's `predict`); `network.server.x.emit(player, ...args)` does the same on
165
+ the server, for tests. A request handler may return a Promise: the answer waits for it.
166
+ - **Flamework networking names:** `createFlameworkCompat<C2SEvents, S2CEvents, C2SFunctions, S2CFunctions>()` returns
167
+ `{ ServerEvents, ClientEvents, ServerFunctions, ClientFunctions, GlobalEvents, GlobalFunctions }` with
168
+ `@flamework/networking` 1.x's methods (`connect` returning a connection, `fire(player | players)`, `broadcast`,
169
+ `except`, `predict`, `setCallback`, `invoke`, `invokeWithTimeout`, calling a leaf directly) on this same network:
170
+ same guards, limits and swap rules, so a Flamework game's call sites compile unchanged. Listeners run on their own
171
+ thread each, like Flamework's, and belong to the running generation (no trove needed). Not carried over: server ->
172
+ client requests, middleware, `Networking.Unreliable`. `typetorch build` notes a game still using it.
173
+ Across a swap (0.3.0): what the server sends a player waits until that player's client runs the new generation
174
+ (reliable messages are queued, unreliable ones dropped), and on kernel 0.3.2 a request the server can no longer
175
+ answer fails at once ("The game is updating, try again.") instead of timing out.
176
+ - **Macros:** `Modding` (from this package) declares compile-time macros that `@typetorch/transformer` fills in:
177
+ `/** @metadata macro */ export function guardOf<T>(guard?: Modding.Generic<T, "guard">) { return guard!; }` makes
178
+ `guardOf<Shape>()` compile to a `t` guard. `Modding.Generic<T, "id" | "text">`, `Many`, `Caller` and `TupleLabels`
179
+ work the same way; see the transformer's README.
180
+ - **UI:** `observeElement(trove, tag, (instance, elementTrove) => ...)`, `isRealFrame`, `popIn` / `popOut` / `bump`
181
+ (UIScale, never Size tweens) and `PopupQueue` (one modal at a time).
182
+ - **Dev menu:** devs (Studio, project members, dev badge) get a DEV button, `Ctrl+Shift+D` and `/tt dev`: artifact
183
+ (with how long the server and client generations have run, the server's uptime and the build's age), server status,
184
+ logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and
185
+ build picker (Server > Branch) and a Claude prompt. The sidebar, top to bottom: Artifact, Server, Network, Modules,
186
+ Manage, Logs, Dex, Claude. The window can be dragged by its header and resized from its corner (double-tap the
187
+ header to reset).
188
+ - **Server TPS (framework 0.4.2, kernel 0.4.2):** Server > Status has a *TPS* line next to Memory and Lua heap: the
189
+ last minute's average, its slowest second and the physics FPS (`59.9 avg, 52.0 min, physics 60`; the warning colour
190
+ under 50 on average). Older kernels show `needs kernel 0.4.2`. The same numbers ride on fleet heartbeats (the
191
+ explorer's fleet table).
192
+ - **Devtools reach devs only (kernel 0.5.6+):** the kernel leaves `devtools` off the shared client tree of a framework
193
+ with `devtools/on-demand.ts`. `runtime/devtools-loader.ts` then clones the folder, minus the server-only modules, into
194
+ each dev's PlayerGui (join, each generation, dev status changes), and that client copies it into its own tree and
195
+ starts it. On older kernels the folder stays in the tree as before. A failure costs a dev the menu, with a warning, and
196
+ never the generation.
197
+ - **Dev-only tools (framework 0.4.1, `devtools/access.ts`):** Claude, Logs > Upload, Dex edits on the server, Network
198
+ packet blocking and the hot-swap sound work under dev rules (private, reserved and Studio servers on a dev branch)
199
+ for every dev, and on a public server an owner switched to a dev branch (Server > Branch) for owners only. Everywhere
200
+ else the menu is read-only, with a short reason: "Dev branch only", "Owners only on public servers", "Owner-switched
201
+ servers only". The rules themselves don't change (`TypeTorch.channel`, signatures, owner-only rollbacks).
202
+ - **Panes and windows (dev menu):** every page shows in a pane with a small header: tap the title to pick another
203
+ page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them.
204
+ The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by
205
+ their title bar and resize from their corner (double-tap the title bar to maximise); their title bar also has
206
+ Minimise, Dock (back into the main panel) and Close. Windows can be split too. The layout, the windows and each
207
+ pane's own settings (for example the open nodes of two Modules > State panes) survive swaps; a fresh join starts
208
+ with one pane. Claude, Server > Branch and the Manage pages open once (picking them again focuses their pane); every
209
+ other page can be open several times. Panes nobody sees (menu closed, window minimised) stop refreshing, and panes
210
+ on the same data share one request (Status, Overview, Assets, Network > Stats, server Logs, Packets); State panes
211
+ share one query budget. On phones and small screens panes only stack (two at most), and windows open maximised with
212
+ one pane each.
213
+ - **State explorer (Modules > State, Server | Client):** every live module of the running generation (services on the
214
+ server, controllers on the client) and the persist store. Tap a row to open it: the module's own fields (not its
215
+ methods), then tables, arrays, Maps and Sets, 100 entries per page (Prev / Next). Each value has a short preview:
216
+ strings (cut), numbers, booleans, nil, datatypes through `tostring`, an Instance as its full path, a Player as its
217
+ name, `function`, `thread`, buffer sizes. Nothing is ever called (raw reads only: no metamethods, no getters); a
218
+ value that is its own ancestor shows as a cycle. A key filter (open nodes stay listed), Refresh and Auto (every 2 s).
219
+ Server state comes from the op `state.inspect` (rate-limited, size-capped): owners everywhere, other devs where the
220
+ dev-only tools are open to them, since server state can hold player data.
221
+ - **Logs > Upload:** sends the logs shown (Server: this server's log ring; Client: your own client's; Others: the
222
+ picked player's client) to the dev PC paired in the Claude tab, which saves them under `<repo>/.typetorch/logs/`.
223
+ No Claude run, no prompt quota. Not paired: "Pair in the Claude tab first".
224
+ - **Switch a live server (kernel 0.3.4):** owners switch any server and load builds on it, public and prod ones too:
225
+ Server > Branch, "Switch" on a branch or "Load here" on a build, then the green Confirm. Everyone stays. These are
226
+ ordinary switches and pins: Reload and Rollback keep working, and going back to prod is Switch on the prod row (its
227
+ verified head). A public switch lasts for that server's lifetime and is never stored, so new servers still boot the
228
+ signed prod head. Others see "Join", which moves only them to a reserved server. Manage > Servers "Load a build..."
229
+ does the same for this server, the ticked servers or a share of one branch (A/B pins), with one status line under
230
+ the list. A public server an owner switched to a dev branch opens the dev-only tools (Claude too) to owners.
231
+ - **Owners and devs:** two roles (no admins: an older kernel's or typetorch.json's "admin" counts as a dev). The Manage
232
+ group (Players, Servers, Bans) is for owners only.
233
+ - **Remote Claude** (a dev-only tool: dev rules, or owners on a public server an owner switched to a dev branch):
234
+ while `typetorch remote-claude` (alias `typetorch dev`; it runs `@typetorch/dev-server`) runs on a dev's machine,
235
+ allowlisted devs prompt Claude Code from the Claude tab. The allowlist is `--users`; without it the dev-server uses
236
+ the owners in the game's `typetorch.json`. Each dev pairs once by pasting the pairing code printed by
237
+ typetorch-dev-server; the game server keeps the session URL and the tokens in memory and never sends them to clients.
238
+ No Roblox secret is needed. (The Studio plugin's Claude tab, with Apply / Deploy to dev / Discard, is a separate
239
+ client of the same dev-server, started by `typetorch serve --claude`; it doesn't go through the framework.)
240
+ - **Remote debug (framework 0.5.0, kernel 0.5.0, `devtools/remote-debug.ts`):** owners can read a fleet server from
241
+ the backend's explorer (`/servers/<JobId>`). The kernel polls the backend, checks the op, the rate and that the
242
+ caller is an owner, then hands the framework's ops to the handler installed through `kernel.onRemoteDebug`. Read-only,
243
+ with the dev menu's own readers and limits: `modules`, `state` (Modules > State queries, rate-limited per caller),
244
+ `assets` (the last hot-asset sync), `network` (per-remote counters, no packet capture), `dex.children` (8 nodes, 200
245
+ rows each at most) and `dex.props`. There are no edit ops. Older kernels don't have the hook, so nothing is
246
+ installed. It is part of the devtools server, so `devtools: false` leaves it out too.
247
+
248
+ ## Runtime API: `TypeTorch`
249
+
250
+ `import { TypeTorch } from "@typetorch/framework"` works on the server and the client. It describes the running
251
+ generation and raises events that belong to it: every listener is dropped when the generation stops, so a swap never
252
+ leaves one behind. Each `on*` returns a disconnect function, which a trove can own.
253
+
254
+ ```ts
255
+ // Identity
256
+ TypeTorch.artifact; // { id, commit, commitHash, branch, channel, builtAt, seq, assetId }
257
+ TypeTorch.generation; // 1, 2, 3... per server (or client)
258
+ TypeTorch.branch; TypeTorch.channel; TypeTorch.serverType; // "public" | "private" | "reserved" | "studio"
259
+ // Kernel 0.3.9: `channel` is the RULES this server runs under ("prod" on every public server; split data stores by it);
260
+ // `branchChannel` is what the branch IS ("prod" for the default branch or one configured prod, else "dev");
261
+ // `rules` is the same as `channel`, by name.
262
+ TypeTorch.branchChannel; TypeTorch.rules;
263
+ TypeTorch.isPinned(); TypeTorch.kernelVersion; TypeTorch.kernelApi; TypeTorch.jobId; TypeTorch.isStudio;
264
+
265
+ // How this generation started: a branch change starts a new generation.
266
+ const start = TypeTorch.startInfo; // { kind: "boot" | "swap", reason, previous?, branchChanged, requestedBy?, loadSeconds? }
267
+ if (start.reason === "auto_rollback") warn("the last deploy failed to start");
268
+ this.trove.add(TypeTorch.onBranchChanged(({ from, to }) => resetBranchData(from, to)));
269
+
270
+ // Before this generation stops (runs before every onStop; keep it short)
271
+ this.trove.add(TypeTorch.onSwapOut((info) => this.saveRound(info.reason)));
272
+
273
+ // A swap is coming: show a small hint, hide it if it's called off
274
+ this.trove.add(TypeTorch.onUpdatePending((update) => (hint.Visible = !update.cancelled)));
275
+
276
+ // State that survives swaps (plain data only)
277
+ const scores = TypeTorch.persist("scores.v1", () => new Map<number, number>());
278
+ // Per player, removed when the player really leaves (same as this.ctx.playerState)
279
+ const cooldowns = TypeTorch.playerState("cooldowns.v1", () => ({ lastUse: 0 }));
280
+ cooldowns.get(player).lastUse = os.clock();
281
+
282
+ // The running module of a type (same as Dependency<T>()), from onInit/onStart on
283
+ TypeTorch.module<ShopService>().open(player);
284
+ TypeTorch.tryModule<ShopService>()?.open(player); // undefined instead of an error
285
+
286
+ // Dev and roles (server: the kernel decides; client: only the local player, cosmetic)
287
+ if (TypeTorch.isOwner(player)) showOwnerPanel(player);
288
+ TypeTorch.onPlayerDevChanged((player, info) => setDevTools(player, info.dev));
289
+
290
+ // Server only
291
+ TypeTorch.status(); // uptime, players, memory, history: cheap
292
+ TypeTorch.branches(); TypeTorch.artifacts(); // registry reads, cached ~30 s: may yield, don't call per frame
293
+ TypeTorch.requestReload(player); // owners only, checked by the kernel
294
+
295
+ // Logs (the kernel's ring buffer)
296
+ TypeTorch.logs(0, 50);
297
+ TypeTorch.onLog((entry) => errors.push(entry)); // don't print from inside it
298
+
299
+ // Hot assets (below): same as hotAsset(...)
300
+ const shop = TypeTorch.asset("ui/shop");
301
+
302
+ // Live values, server only (kernel 0.3.8): the signed settings record's `game` field (`typetorch settings set game.<key> <json>`).
303
+ const price = TypeTorch.liveConfig("shop.price", { default: 50, parse: (raw) => (typeIs(raw, "number") ? raw : 50) });
304
+ price.get();
305
+ this.trove.add(price.onChanged((value) => this.reprice(value)));
306
+
307
+ // Cross-server, server only. Kernel 0.3.8: game topics over ONE kernel-held MessagingService topic; listeners hear
308
+ // prod servers and this branch by default (dev branches never reach prod); publish queues and retries, max 1 KiB.
309
+ this.trove.add(TypeTorch.messaging.subscribe<Ban>("1guard", (ban, meta) => applyBan(ban, meta.jobId)));
310
+ TypeTorch.messaging.publish("1guard", { kind: "ban", userId }, { to: "prod" });
311
+
312
+ // The universe's live servers (the roll call; cached per server, yields up to ~3 s when stale) and this server's public fields
313
+ TypeTorch.setServerInfo({ region: "eu", vc: true }); // JSON, at most 400 bytes, kept across swaps
314
+ const list = TypeTorch.servers(); // [{ jobId, placeVersion, players, maxPlayers, serverType, branch, channel, uptime, here, info }]
315
+
316
+ // A library call a swap must not cut off halfway (kernel 0.3.8): runs on a kernel thread; write what must survive into persist.
317
+ TypeTorch.runDetached(() => store.StartSessionAsync(`Player_${userId}`)).then((profile) => attach(player, profile));
318
+ ```
319
+
320
+ - **`startInfo.reason`:** `boot`, `deploy`, `rollback`, `branch`, `pin`, `reload`, `server_rollback`,
321
+ `auto_rollback` or `unknown`. A client's first generation is always `boot`. `requestedBy` (a user id), `loadSeconds`,
322
+ `stopSeconds` and `swapSeconds` are server-only; `swapSeconds` appears once the swap has finished.
323
+ - **`persist(key, init)`** keeps a table in the kernel's memory for the server's lifetime; every later generation gets
324
+ the same table back. Store plain data only: tables, arrays, Maps and Sets of strings, numbers, booleans, and
325
+ Players. Never store functions, class instances, Promises, threads, connections, charm atoms or instances this
326
+ generation created: they keep the old generation's code alive or are destroyed by its trove. Version the key when
327
+ the shape changes. Keys starting with `__` are reserved.
328
+ - **`onSwapOut`** runs synchronously before every module's `onStop`, so modules can still save into `persist`. It
329
+ doesn't run on server shutdown (kernel 0.3.2 runs `onStop` then; on older kernels use `game.BindToClose`).
330
+ - **Kernel versions:** `TypeTorch.features` says what the running kernel supports. Kernel 0.2.2 adds the start
331
+ reason and timings, the next artifact in `onSwapOut`, `onUpdatePending`, server-side `onPlayerDevChanged` and
332
+ `requestReload`. On older kernels `startInfo` still knows boot vs swap, the previous artifact and branch (the
333
+ framework records them), `onBranchChanged` still fires, and the reason of a swap is `unknown`.
334
+ - **`messaging`** (kernel 0.3.8): `subscribe(topic, (data, meta) => ...)` returns a disconnect; `meta` = `{ channel,
335
+ branch, jobId, serverType, placeVersion, sentAt, self, replayed? }`; `{ from: "all" | "prod" | "branch" }` changes
336
+ who it hears. `publish(topic, data, { to })` throws on a bad topic, non-JSON data or a message over 1 KiB (with its
337
+ size), returns false when dropped at once (queue full). Studio loops back; the cloud test publishes nothing; older
338
+ kernels throw "needs kernel 0.3.8". `servers()` works on every kernel (0.3.8 holds the roll call topic, so a swap
339
+ doesn't subscribe again).
340
+ - **`liveConfig(key, { default, parse? })`** (kernel 0.3.8, plans/20): reads `game[key]` of the signed settings record the
341
+ kernel verified (signed by both prod keys, so game code can't forge it); `get()` and `onChanged(fn)` (only on real
342
+ changes; generation-scoped). The default without a record or key, when `parse` throws (warned per value), and on older
343
+ kernels (warned once: "needs kernel 0.3.8"; no ConfigService fallback). `TypeTorch.settings()` is the whole copy
344
+ (`seq`, `at`, `game`, `analytics`, `fleet`, ...): server only, it holds tokens.
345
+ - **`runDetached(fn)`** (kernel 0.3.8, server only): runs `fn` on a thread the kernel owns, so a deploy's hard stop
346
+ (which kills this generation's threads mid-call) can't cut it off: for ProfileStore / ProfileService loads, saves and
347
+ releases. Returns a Promise that settles while this generation runs; after a swap the result is dropped, so a job
348
+ whose result must survive writes it into `persist` itself. The job keeps this generation's closures alive until it
349
+ ends. Errors reject it and go to the kernel's log with the generation's name (never the health window); at most 256
350
+ run at once per server (then it throws); one past 60 s is logged and flagged in Server > Status. Older kernels
351
+ throw "needs kernel 0.3.8; use the DataHost job queue (Player data guide)": the other option, which works on every
352
+ kernel. `features.runDetached` tells which one this server has.
353
+ - **Edit mode** (UI Labs stories, no kernel): `running` is false, identity has defaults, `persist` keeps a local table,
354
+ events never fire, and the server-only reads throw.
355
+
356
+ ## Player data
357
+
358
+ Saving player data is game code: pick any library (ProfileStore, DataStore2, your own). TypeTorch only gives you the
359
+ pieces that keep it safe across swaps:
360
+
361
+ - **The library lives in the place**, outside the payload, and a small place Script requires it first. Its open
362
+ sessions, autosave loop and shutdown hook then survive every swap (a swap stops the generation's scripts).
363
+ - **Session handles live in `persist`**, keyed by `UserId`. `onPlayerAdded` replays everyone after a swap, so it
364
+ re-attaches to the open session instead of loading again.
365
+ - **Release only on `Players.PlayerRemoving`**, never in `onStop` or `onSwapOut`.
366
+ - **Library calls can't be cut off by a swap:** run them as DataHost jobs (any kernel) or with
367
+ `TypeTorch.runDetached` (kernel 0.3.8).
368
+ - **Split store names by channel** (`TypeTorch.channel === "prod" ? "PlayerData" : "PlayerData_dev"`).
369
+
370
+ Full example with ProfileStore: [Player data guide](https://github.com/typetorch/docs/blob/main/guides/player-data.md).
371
+
372
+ ## Hot assets: `hotAsset`
373
+
374
+ Builders mark models and UI templates in the place with the attribute `TypeTorchAsset` (a key such as `"ui/shop"`),
375
+ `typetorch assets sync` uploads them, and the deploy puts the asset manifest in the artifact. Running servers then get
376
+ new versions live, with no restart.
377
+
378
+ ```ts
379
+ import { hotAsset } from "@typetorch/framework"; // or TypeTorch.asset("ui/shop")
380
+
381
+ // In a controller's onStart: rebuild clones the template.
382
+ const shop = hotAsset("ui/shop");
383
+ shop.changed(rebuild, this.trove); // a new version went live
384
+ rebuild(shop.get());
385
+ ```
386
+
387
+ - **`hotAsset(keyOrId, fallback?)`** works on the server and the client. A number is the Roblox asset id, resolved
388
+ through the manifest (server) or the live copy's `TypeTorchAssetId` attribute (client).
389
+ - `get()` returns the live copy now. With none, it returns `fallback` (an instance you already hold, such as the
390
+ template in the place) if it is still parented, else `undefined`. **Clone it; don't parent or edit it:** a new
391
+ version destroys it.
392
+ - `wait(timeout?)` is `get()` that waits for a live copy (forever without a timeout).
393
+ - `changed(fn, trove?)` calls `fn(instance)` each time a new copy replaces the live one: a new version, a rollback,
394
+ or the first copy reaching a client. It returns a disconnect function. Pass the module's trove so the connection
395
+ ends with it (`shop.changed(rebuild, this.trove)` or `this.trove.add(shop.changed(rebuild))`). Without a trove it
396
+ ends when the generation stops.
397
+ - `key` and `version` (the live copy's `TypeTorchAssetVersion`, an assetVersionId).
398
+ - **Clients never request anything.** They read the CollectionService tag `__typetorch_asset:<key>`, and replicated
399
+ assets arrive through replication. Assets under ServerStorage stay server-only.
400
+ - **AssetSync** is a server built-in. It runs on every generation start, before any module loads (top-level code
401
+ included). For each manifest key:
402
+ 1. it keeps the live copy that already has the manifest's version;
403
+ 2. else it adopts the place's own copy, if its `TypeTorchAssetHash` matches, or if the place holds exactly the
404
+ copies the manifest was exported from: the manifest's optional `placeVersion` is this server's place version, or
405
+ the place's backup build (`ServerStorage.TypeTorchBackup`, attributes `HotAssetsPlaceVersion` /
406
+ `HotAssetsSavedAs`) vouches that this place version is a save of that one which changed nothing outside the
407
+ kernel slots (the CLI's backup refresh);
408
+ 3. else it runs `InsertService:LoadAssetVersion(ver)` (all loads in parallel), strips any scripts, gives back the
409
+ CollectionService tags the place's copy has (uploaded versions arrive without them, so a game that finds its UI
410
+ by tag keeps working), places the copy at its path (creating missing Folders), tags it, then destroys the copy it
411
+ replaces.
412
+
413
+ It holds the start for at most 8 s. Loads still running after that keep going, swap in when done and fire
414
+ `changed`. A failed load keeps the old copy (on a new server, the place's copy) and shows under Server > Status >
415
+ Attention.
416
+ - **Hot assets persist across swaps.** They aren't in any trove. A swap changes them only when the manifest changes,
417
+ and a rollback brings back the older versions. **Keys the manifest doesn't name are left as they are:** AssetSync
418
+ never deletes builders' content. A key dropped from the manifest keeps its last live copy.
419
+ - **Clones never count.** A clone keeps the tag and the attributes, but only the copy whose parent is its
420
+ `TypeTorchAssetPath` (stamped by AssetSync) is the live one. So a template cloned into PlayerGui never shows up in
421
+ `get()`, and AssetSync never destroys it.
422
+ - **Dev menu:** Modules > Assets (Server | Client) lists each key's source (kept, baked, loaded, failed), version,
423
+ load time and last error. With analytics on, each generation also sends one `tech` / `assets` row (the place match
424
+ and the counts per source; `src/analytics/SCHEMA.md`).
425
+
426
+ ## Analytics: `AnalyticsEngine`
427
+
428
+ Optional, and all ours: the game server sends rows to the backend the dev picked (Cloudflare Basin streams, or a
429
+ self-hosted DuckDB analytics server). Nothing runs until an engine is created: no connections, threads or requests.
430
+
431
+ ```ts
432
+ import { AnalyticsEngine } from "@typetorch/framework";
433
+
434
+ // A server module (onInit): the sink comes from the signed settings' `analytics` (kernel 0.3.8).
435
+ const analytics = new AnalyticsEngine();
436
+ analytics.track(player, "quest_done", { quest: "tutorial" }); // custom
437
+ analytics.step(player, "onboarding", 3, "opened_shop"); // funnels
438
+ analytics.purchase(player, { product: 1234, robux: 99, where: "shop" }); // after the receipt is granted
439
+ analytics.currency(player, "coins", 50, "round_reward"); // economy in (+) and out (-)
440
+ analytics.state("round"); // every player's activity; analytics.state(player, "shop") for one
441
+ const variant = analytics.experiment(player, "onboarding", ["short", "long"]); // first = control
442
+
443
+ // A client controller (onStart): everything goes through the server, never to the internet.
444
+ const analytics = new AnalyticsEngine();
445
+ analytics.track("opened_map");
446
+ analytics.screen("Inventory"); // for UIs that aren't separate ScreenGuis
447
+ const variant = analytics.experiment("onboarding", ["short", "long"]); // same answer as the server's
448
+ ```
449
+
450
+ - **One engine per generation and realm.** Every `new AnalyticsEngine()` joins the one already running (the first one's
451
+ options win), so any module can create its own. It stops with the generation; its unsent rows wait in `persist`
452
+ and the next generation sends them. In edit mode (UI Labs) it is inert.
453
+ - **Server calls take the player first** (`track(player, ...)`); without one an event is server-only (no player id).
454
+ Client calls are always about the local player and are marked `src = "client"` (a client can lie). The server
455
+ checks their shape, size (props at most 4 KB) and rate. `purchase` and `currency` are server-only (revenue and the
456
+ economy are server-authoritative): on the client they warn once and send nothing, and the server refuses them from
457
+ clients.
458
+ - **Options** (all on by default): `sessions` (joins, leaves, device, join source, account age bucket, Premium,
459
+ country, friends in the server, first-ever vs returning, days since the last visit), `tech` (FPS, ping, memory, load
460
+ time, client and server errors, swaps and rollbacks, leaves within 60 s of a swap), `zones` (parts or models tagged
461
+ `TTZone`, named by a `Name` attribute or the instance name), `screens` (ScreenGuis in PlayerGui, GuiObjects tagged
462
+ `TTScreen`), `recording` (the first-ever session in detail), `fleet` (kernel 0.3.2 heartbeats and deploy reports),
463
+ `identity` (one `{ pid, uid }` row per session to the dev's own server, below). `settings` (server only) replaces the
464
+ signed settings' `analytics`, for tests, Studio and kernels before 0.3.8.
465
+ - **Every row** carries the time (server clock), a random player id (never the UserId), the session, the server
466
+ (JobId, type, place), the artifact (id, seq, branch, channel), the device, new vs returning, the player's state
467
+ (`zone:Lobby|screen:Shop|activity:round`) and experiment variants. The exact rows: `src/analytics/SCHEMA.md`.
468
+ - **Experiments:** `experiment(...)` is deterministic per player and name (they keep their variant in every session),
469
+ may yield briefly the first time (until the player's id loads), and stamps the variant on the player's later events.
470
+ The settings turn one off (`active: false`: everyone gets the first variant), sets `weights` or forces a
471
+ `variant`, live. `players` (framework 0.5.3+) forces a variant for single players, keyed by UserId or pid, over
472
+ everything else (QA, support); a client only ever gets its own entry. The CLI writes these with `typetorch
473
+ experiment weights | settle | stop | resume | clear | force | unforce`.
474
+ - **First-ever session:** for new players (a share of them, `recordShare`), the client records character and camera
475
+ about 10 times a second, every input (never while a TextBox or the chat has focus; text boxes only say which box was
476
+ used), buttons pressed and hovered, screens, prompts and deaths, from the join until 60 s after the first input.
477
+ Packed into small binary chunks (10-20 KB a player) and sent through the server.
478
+ - **Never collected:** chat or anything typed, usernames and display names (error texts have them replaced). No event
479
+ row carries a UserId.
480
+ - **Identities** (option `identity`, default on): once the pid is known, one row `{ pid, uid, t }` (the UserId, nothing
481
+ else) per session goes to the dev's own server only, never into the events: duckdb games in the batch body, Basin
482
+ games to the backend (the record's `backend` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
483
+ analytics server keeps pid -> UserId in a deletable table (support lookups, Right to Erasure).
484
+ - **Player ids:** DataStore `TypeTorchAnalytics`, key `p/<UserId>` -> `{ pid, first, last }`: one read per join, a
485
+ write on the first join and at leave. Deleting the key (Right to Erasure) leaves that player's rows anonymous.
486
+ - **Sending:** one queue on the server (10,000 events; over that the oldest are dropped and counted), a flush every
487
+ `flushSeconds` (15) or at 500 rows, at most ~10 HttpService requests a minute, retries with backoff, and a last
488
+ flush on shutdown (kernel 0.3.2). Delivery is at least once (a swap mid-request sends that batch again).
489
+ `analytics.stats()` (server) has the counters; `flush()` sends soon.
490
+ - **When uploads fail** (a stale quick-tunnel URL, the dev PC's server or tunnel down, a wrong token, HttpService off;
491
+ Roblox reports most network problems as `HttpError: NetFail`, `DnsResolve` or `ConnectFail`): the server backs off
492
+ with jitter (5, 10, 20 ... 300 s, spread +-25% so a fleet of servers doesn't retry in step; a wrong token or URL waits
493
+ about 5 minutes, and a settings change retries at once), keeps the rows, and logs at most ONE line a minute: the
494
+ reason and the fix (`analytics upload failed 5 times in a row: NetFail: the connection broke mid-request. ... Retrying
495
+ in 80 s.`, with "(+N more in the last minute)" for the lines it held back) and one line when uploads work again.
496
+ `stats()` also has `failures` (in a row), `failed` (since the server started), `lastError`, `lastStatus`
497
+ (0 = no answer), `lastErrorAt` / `lastOkAt` (`os.time()`), `retryIn`, and, while failing, `reason` and `fix`. The dev
498
+ menu shows them: **Server > Status** has an *Analytics* line (queued, sent, `FAILING x5: ... retry in 80 s`) and a
499
+ *Fleet API* line (the kernel's sender: sent, failed, last error and its age), and **Attention** lists the reason and
500
+ the fix; `typetorch doctor` tests the address and token in the settings record.
501
+ - **The cloud test sends nothing.** `typetorch test --cloud` boots the payload headless in a Luau Execution task where
502
+ HttpService works; there the engine (stub kernel `test = true`, or `workspace:GetAttribute("TypeTorchTest")`) collects
503
+ as usual but never uploads: no event rows, no identity rows, no HTTP request, so a prod deploy never puts a fake
504
+ server session into your analytics.
505
+ - **Bounds on what a client can cause:** at most 32 experiments per session (each is stamped on every later row), and
506
+ a client's `exp` messages count against its event budget (120 a minute, 5,000 a session). Clients can't send
507
+ `purchase` or `currency` rows; the analytics server's ingest also refuses client-marked ones (older engines) and its
508
+ revenue queries count `purchase` rows the server sent.
509
+ - **Friends in the server** (the join's `friends`): one `Players:GetFriendsAsync` per join (at most 10 pages, none
510
+ when the player is alone) intersected with the players there, not a web call per player.
511
+
512
+ **Settings, framework 0.4.0 / kernel 0.4.0 (plans/21):** the record's `backend` = `{ url, key, analytics?: { flushSeconds,
513
+ recordShare } }` (`typetorch backend setup`) is the whole sink: DuckDB at `<url>/v1/ingest` with the one key (Basin
514
+ identities to `<url>/v1/identity`). The old `analytics` value below is read while `backend` is missing, and stays the
515
+ way to a Basin sink. The engine also tells the kernel each player's pid (`setAnalyticsId`: the kernel's error reports
516
+ count affected players by pid, never by name) and counts its own DataStore and HTTP requests for the budget view (dev
517
+ menu **Server > Budget**: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
518
+
519
+ **Settings** (server only, never sent to clients): the signed settings record's `analytics` field (kernel 0.3.8,
520
+ plans/20), derived from `backend` by `typetorch backend setup` (`writeBackendSettings()` in `@typetorch/backend` runs it, and
521
+ `bun run local` too). Every new copy applies live within seconds (the CLI pings servers). A Basin sink has no CLI writer
522
+ since CLI 0.9: `backend setup` replaces it. Before kernel 0.3.8
523
+ there are no settings unless the game passes `new AnalyticsEngine({ settings })`:
524
+
525
+ ```json
526
+ { "backend": "basin", "events": "https://<stream-id>.ingest.cloudflare.com",
527
+ "recordings": "https://<stream-id>.ingest.cloudflare.com", "token": "<write-only token>",
528
+ "flushSeconds": 15, "recordShare": 1, "techEvery": 60,
529
+ "experiments": { "onboarding": { "weights": [1, 1] } } }
530
+ ```
531
+
532
+ - `basin`: a JSON array per stream (`events`, `recordings`), `Authorization: Bearer <token>` when set (a token with
533
+ Basin Pipelines Send permission, if the stream requires authentication). Without `recordings` nothing is recorded.
534
+ The streams' schemas must match SCHEMA.md exactly: Basin drops rows that don't, silently.
535
+ - `duckdb`: one `POST <events>` with `{"events":[...],"recordings":[...]}`, gzip, `Authorization: Bearer <token>`.
536
+ - The token is write-only and never logged. Needs **Allow HTTP Requests** (Game Settings > Security). No key: the
537
+ engine collects but keeps only the newest 1,000 rows until settings appear. Removing the key stops sending, live.
538
+
539
+ ## Develop
540
+
541
+ ```sh
542
+ bun install
543
+ bun run build # rbxtsc --type package -> out/
544
+ ```
545
+
546
+ - **Runtime of the transformer:** `src/reflection/reflect.ts` and `modding.ts` are copies of
547
+ `@typetorch/transformer`'s `runtime-kit/` (the source of truth); keep them identical. The framework compiles itself
548
+ without the transformer (only `rbxts-transform-debug`): it declares macros, which expand in the game's compile.
549
+ - **Versions:** bump `src/version.ts` (`FRAMEWORK_VERSION`, shown in uploaded log headers) with package.json, and
550
+ `LATEST_KERNEL` in `src/devtools/health.ts` when the framework starts relying on a newer kernel.
551
+ - **Tests (Lune, offline):** `scripts/test-*.luau`; each file's header has its command (Lune is pinned in the
552
+ kernel's and the template's `rokit.toml`). `scripts/test-generations.luau` takes a game's built payload:
553
+ `cd ../template && bun run payload && lune run ../framework/scripts/test-generations.luau build/payload.rbxm` boots
554
+ two generations in one VM and checks fresh registries, DI and generated guards, then runs the real `startServer`
555
+ with stub kernels (onStart failures reported to kernel 0.3.2, raised on older ones; onClose), the health lines and
556
+ the Branch tab / "Load a build" rules (`devtools/build-actions.ts`).
557
+ To test framework changes before the template takes them, build the payload from a copy of the template whose
558
+ `node_modules/@typetorch/framework/out` is this repo's `out/`.
559
+ `scripts/test-state-inspect.luau` checks the state explorer's core (previews, raw reads only, cycles, paging,
560
+ Maps/Sets with Instance keys, the depth limit, filters, request validation) and that `src/version.ts` matches
561
+ package.json.
562
+ `scripts/test-layout.luau` checks the dev menu's pane layout core (`devtools/layout.ts`: splits and their limits,
563
+ closing, pop out and dock, singleton pages, ratios, focus, and cleaning a remembered layout).
564
+ `scripts/test-modules.luau` runs the real `startServer`/`startClient` of `out/` (with RuntimeLib, Promise, trove and
565
+ t from node_modules) over several generations that share a persist store: `Dependency<T>()`, `TypeTorch.module` /
566
+ `tryModule` and their errors, `Lazy<T>` (parameters and fields, cycles, start order), network `emit` and request
567
+ timeouts and `playerState` (swaps, leaves).
568
+ `scripts/test-flamework-compat.luau` (same harness) drives `createFlameworkCompat` through each generation's
569
+ dispatcher: connect (one thread per call, guards, Disconnect, troves), fire / except / broadcast / the call
570
+ shorthand, setCallback with values and Promises, predict, a swap, and the client side (invoke, invokeWithTimeout,
571
+ `createClient({ defaultTimeout })`).
572
+ `scripts/test-analytics.luau` checks the analytics engine's pure parts (experiment assignment, settings, the queue
573
+ and HTTP budget, tt-rec-1, the sink request bodies, the failure help); `test-analytics-server.luau` and
574
+ `test-analytics-client.luau` run the compiled server and client cores against mocked services (sessions, intake,
575
+ retries, a NetFail streak and its one log line a minute, a swap with a request in flight, shutdown; the recorder,
576
+ screens, batching). `scripts/test-net-health.luau` checks the dev menu's Fleet API / Analytics status lines and
577
+ Attention issues.
578
+ The others, each on the compiled `out/`: `test-hot-assets` (manifest parser, AssetSync decisions incl. the backup
579
+ vouch, live-copy rules), `test-messaging` (messaging and the server list), `test-settings` (`liveConfig` and the
580
+ settings lines), `test-detached` (`runDetached`), `test-dev-access` (the dev-only tools rule and every gate through
581
+ it), `test-remote-debug` (the remote debug ops and their limits), `test-net-inspect` (packet capture),
582
+ `test-explorer-core` (Dex), `test-toolbox-sanitize` (the Creator Store sanitizer) and `test-sha256` (the pairing-code
583
+ fingerprint). `node scripts/check-self-calls.mjs out` flags compiled functions that call themselves on their first
584
+ lines (after `bun run build`).
585
+ - **Package contents:** only `out/` (no `.tsbuildinfo`), `README.md` and `LICENSE`; check with `bun pm pack --dry-run`.
586
+ - **Explorer class icons:** `assets/class-icons.png` (kept locally, not in the repo: it is Roblox's own texture) is
587
+ the client's `content/textures/ClassImages.PNG` (a 2352x16 strip) repacked into a 32-column grid, because live
588
+ clients downscale textures wider than 1024 px:
589
+ `ffmpeg -i ClassImages.PNG -vf "untile=147x1,tile=32x5:color=0x00000000" class-icons.png`. It is uploaded as image
590
+ `rbxassetid://97389585475400`; `scripts/gen-explorer-icons.ts` generates the index table.
591
+
592
+ ## Release
593
+
594
+ Publishing is tag-only, from CI (`.github/workflows/publish.yml`), with npm trusted publishing (OIDC, provenance):
595
+
596
+ ```sh
597
+ # first commit src/version.ts set to the new version (npm version needs a clean tree)
598
+ npm version 0.5.6 -m "release %s"
599
+ git push origin main --follow-tags
600
+ ```
601
+
602
+ The workflow runs on a `v*` tag, checks that the tag matches package.json and that the version isn't on npm yet, then
603
+ `npm publish --provenance` (which runs `prepublishOnly`: a clean `rbxtsc --type package` into `out/`) and creates the
604
+ GitHub release with generated notes. A prerelease version (`x.y.z-next.0`) goes to the `next` dist-tag and a GitHub
605
+ prerelease; anything else to `latest`. Trusted publishing is set on npmjs.com (@typetorch/framework > Settings >
606
+ Trusted publishing: this repo, workflow `publish.yml`, environment `npm`); an `NPM_TOKEN` repo secret is the fallback.
607
+ Don't run `npm publish` by hand. Games pick the new version up with `bun update @typetorch/framework` and their next
608
+ deploy, since the framework ships inside the payload.
609
+
610
+
611
+ MIT licensed.