Appearance
Synced from the framework README.
@typetorch/framework
The roblox-ts framework of TypeTorch: hot-swappable modules (dependency injection, lifecycle, troves), guarded networking over the kernel's stable remotes, UI helpers and the in-game dev menu.
It ships inside every artifact, so each generation runs its own fresh copy and framework fixes hot-swap like game code. It needs the TypeTorch kernel in the place (@typetorch/kernel), which calls your Server/boot and Client/boot modules.
How it fits with the other TypeTorch packages
| Package | Role | Where it runs |
|---|---|---|
@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) |
@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 |
@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) |
@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 |
@typetorch/dev-server | remote-claude: the dev menu's Claude tab and Logs > Upload talk to it. | a dev's machine |
The framework never talks to the CLI directly: the CLI builds and signs payloads and settings, the kernel loads them, and the framework reads what the kernel hands it. Features that need a newer kernel check TypeTorch.features and say "needs kernel x.y.z" on older ones; the dev menu flags kernels older than LATEST_KERNEL (src/devtools/health.ts, now 0.5.3) with a Migrate issue.
Install
sh
npm i @typetorch/framework
npm i -D @typetorch/transformer(bun add @typetorch/framework and bun add -d @typetorch/transformer work the same.) The framework is a roblox-ts 3 package; @typetorch/transformer is its compiler plugin: it generates the network guards, the constructor dependency ids of @Service / @Controller and your own macros. Nothing from Flamework is needed: Modding, Reflect and t come from this package. The starter game (template) has it all set up: clone it and run bun install.
tsconfig.json:
jsonc
{
"compilerOptions": {
"experimentalDecorators": true,
// Every npm scope the game imports from must be a type root (roblox-ts rule).
"typeRoots": ["node_modules/@rbxts", "node_modules/@typetorch"],
"types": ["types", "compiler-types"],
"plugins": [
// Optional, first when present: $print/$warn with file and line. @typetorch/transformer 0.3.2+ has the same
// macros built in (`@typetorch/transformer/macros`; it also expands calls typed by rbxts-transform-debug).
{ "transform": "rbxts-transform-debug", "environmentRequires": {} },
{ "transform": "@typetorch/transformer" }
]
}
}The payload is a Rojo Model project. Map only this package from the @typetorch scope (the transformer is Node code and the kernel lives in the place):
json
"include": {
"$path": "include",
"node_modules": {
"$className": "Folder",
"@rbxts": { "$path": "node_modules/@rbxts" },
"@typetorch": { "$className": "Folder", "framework": { "$path": "node_modules/@typetorch/framework" } }
}
}Each generation requires its own copy of the payload, so the framework and its Reflect registry start fresh on every swap. It needs the TypeTorch kernel in the place (@typetorch/kernel).
Coming from Flamework
Game code only changes imports: @Service, @Controller, constructor injection, Dependency<T>(), the lifecycle interfaces and createNetwork are the same.
| Flamework | TypeTorch |
|---|---|
rbxts-transformer-flamework plugin, node_modules/@flamework type root | @typetorch/transformer plugin, node_modules/@typetorch type root |
import { Modding, Reflect } from "@flamework/core" | import { Modding, Reflect } from "@typetorch/framework" |
import { Dependency } from "@flamework/core" | import { Dependency } from "@typetorch/framework" (from onInit/onStart on, not in constructors) |
import type + Dependency<T>() to break a cycle | the same, or Lazy<T> (below) |
ClientEvents.x.predict(...) | network.client.x.emit(...) |
invokeWithTimeout(timeout, ...) | invokeWithTimeout(seconds, ...) (seconds, 0.5 to 120) |
Observers.observeCharacter / observeLocalCharacter (@rbxts/observers) | still fine: put the stop function in the trove, this.trove.add(observeCharacter(...)) |
import { t } from "@rbxts/t" (still fine) | also import { t } from "@typetorch/framework" |
@metadata flamework:parameters keys, "flamework:parameters" metadata | @metadata typetorch:parameters, "typetorch:parameters" |
flamework.build, include/flamework, the @flamework Rojo mapping | gone; delete them |
Networking.createEvent / createFunction and their call sites | createFlameworkCompat (below) keeps every call site; createNetwork is the native API |
typetorch migrate --from flamework (CLI) does most of this, and the docs' guides/from-flamework.md has the whole mapping.
Usage
ts
// src/server/boot.ts
import { startServer, type ServerKernel } from "@typetorch/framework";
import { BUILD } from "../shared/build"; // written by `typetorch build`
export function boot(kernel: ServerKernel) {
return startServer(kernel, { modules: [script.Parent!.FindFirstChild("services")!], build: BUILD });
}
// src/shared/net.ts
import { createNetwork, type ProperReturns } from "@typetorch/framework";
interface ClientToServer { coins: { collect(coinId: string): void; balance(): ProperReturns<number> } }
interface ServerToClient { coins: { changed(total: number): void } }
export const network = createNetwork<ClientToServer, ServerToClient>();
// src/server/services/coin.service.ts
import { Module, Service, type OnStart } from "@typetorch/framework";
import { network } from "../../shared/net";
import { ScoreService } from "./score.service";
@Service()
export class CoinService extends Module implements OnStart {
constructor(private readonly score: ScoreService) { // injected
super();
}
onStart() {
this.trove.add(network.server.coins.collect.on((player, coinId) => this.score.add(player, 1)));
this.trove.add(network.server.coins.balance.handle((player) => [this.score.get(player)]));
}
}startClient(kernel, options) is the same for src/client/boot.ts with your controllers folder. StartOptions: modules (folders whose ModuleScripts are required, recursively, so their decorators register), build (the generated build info, shown in the dev menu) and devtools (default true; false leaves the dev menu out). Both return the generation's stop function, which the kernel calls on a swap.
- Modules:
@Service()(server) and@Controller()(client) classes extendingModule. Constructor parameters are other modules, injected by type. Lifecycle:OnInit(sequential, dependencies first),OnStart(spawned),OnStop(reverse order),OnTick,OnPhysics,OnRender,OnPlayerAdded(replays players already in the server).@Service({ loadOrder: -1 })breaks ties between modules with no dependency relation (lower first, default 0).this.ctxhas the realm, artifact, branch, channel, generation, build info, the raw kernel,persistandplayerState.observePlayers(trove, (player, playerTrove) => ...)isOnPlayerAddedfor code outside a module. - Modules outside the constructor:
Dependency<T>()(orTypeTorch.module<T>()) returns the running module of type T from anywhere: a method, a plain class a module built, a command handler.TypeTorch.tryModule<T>()returns undefined instead of throwing. They work once every module is constructed (from onInit on); in a constructor, a field initializer or module top-level code they throw"ShopService isn't constructed yet: ...". Only this realm's modules, and per generation: after a swap they return the new generation's instance. Animport typeof the class is enough (no require cycle). - Cycles: two modules that need each other take one side as
Lazy<T>, resolved on first use:constructor(private readonly team: Lazy<TeamService>)(needs@typetorch/transformer0.2.1+) or a field,private readonly team = Lazy<TeamService>()(any transformer).this.team.get()works from onInit on (in a constructor it throws), and the start order ignores lazy edges. A cycle error names the cycle and suggests this. - Per-player state:
this.ctx.playerState("cooldowns.v1", (player) => init)(orTypeTorch.playerState) returns{ get, set, has, delete }keyed by UserId. It survives swaps (persist), and a player's entry goes when they really leave, never on a swap. The dev menu shows it under Modules > State > persist,__playerState. - Bad deploys roll back (kernel 0.3.2): a server
onStartthat throws is reported to the kernel, which rolls the server back to its last known good artifact when it happens within 30 s of start (so do 3 errors from the new code's scripts in that time). On a real shutdown every module'sonStopruns too (the kernel's BindToClose). - Troves: every module gets
this.trove; everything it creates or connects goes there, so a swap leaves nothing behind.TypeTorch.persist(key, init)(orthis.ctx.persist) keeps plain data across swaps. - Network:
createNetwork<C2S, S2C>()with nested namespaces. The server checks rate limits, shape limits and the generated type guard on every client message.setNetworkLimits({ "chat.say": { maxString: 200 } })tunes a leaf. Requests wait 15 s for the answer:invokeWithTimeout(5, ...args)sets it per call, and a leaf'stimeout(setNetworkLimits({ "vc.spawn": { timeout: 30 } }), read by the client, so set it in shared code) per leaf; both in seconds, 0.5 to 120.network.client.x.emit(...args)runs this client's ownonhandlers for a server -> client leaf right away, with no traffic (Flamework'spredict);network.server.x.emit(player, ...args)does the same on the server, for tests. A request handler may return a Promise: the answer waits for it. - Flamework networking names:
createFlameworkCompat<C2SEvents, S2CEvents, C2SFunctions, S2CFunctions>()returns{ ServerEvents, ClientEvents, ServerFunctions, ClientFunctions, GlobalEvents, GlobalFunctions }with@flamework/networking1.x's methods (connectreturning a connection,fire(player | players),broadcast,except,predict,setCallback,invoke,invokeWithTimeout, calling a leaf directly) on this same network: same guards, limits and swap rules, so a Flamework game's call sites compile unchanged. Listeners run on their own thread each, like Flamework's, and belong to the running generation (no trove needed). Not carried over: server -> client requests, middleware,Networking.Unreliable.typetorch buildnotes a game still using it. Across a swap (0.3.0): what the server sends a player waits until that player's client runs the new generation (reliable messages are queued, unreliable ones dropped), and on kernel 0.3.2 a request the server can no longer answer fails at once ("The game is updating, try again.") instead of timing out. - Macros:
Modding(from this package) declares compile-time macros that@typetorch/transformerfills in:/** @metadata macro */ export function guardOf<T>(guard?: Modding.Generic<T, "guard">) { return guard!; }makesguardOf<Shape>()compile to atguard.Modding.Generic<T, "id" | "text">,Many,CallerandTupleLabelswork the same way; see the transformer's README. - UI:
observeElement(trove, tag, (instance, elementTrove) => ...),isRealFrame,popIn/popOut/bump(UIScale, never Size tweens) andPopupQueue(one modal at a time). - Dev menu: devs (Studio, project members, dev badge) get a DEV button (in Roblox's top-bar row since 0.6.2,
GuiService.TopbarInset, so it never covers a game's top-left HUD),Ctrl+Shift+Dand/tt dev: artifact (with how long the server and client generations have run, the server's uptime and the build's age), server status, logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and build picker (Server > Branch) and a Claude prompt. The sidebar, top to bottom: Artifact, Server, Network, Modules, Manage, Logs, Dex, Claude. The window can be dragged by its header and resized from its corner (double-tap the header to reset). - Command palette (framework 0.5.7;
Ctrl+K,Cmd+Kon a Mac, or the search button in the menu header): opens the dev menu if it is closed and searches every command the dev may run now (fuzzy, every word against the title, keywords or group:swdevfinds Switch to dev branch). Up / Down pick, Enter runs, Shift+Enter opens a page in a split, Escape closes; recent commands come first on an empty query. Never fires while typing in chat or a text box. Commands: open any page; split right / down, open the pane in a new window, close it, dock all windows; Switch to dev branch, Reload, Roll back, Migrate all players to a new server, switch branch, load a build here, join a server on a branch or build (the Branch tab's rules and words,devtools/build-actions.ts,devtools/palette-rules.ts), New server and Join a listed server (owners); find a player by name, display name or UserId and open them in Manage > Players or their Kick / Ban card, or Teleport to / Bring / Respawn them (owners, Manage > Players' permissions); copy the job id, artifact id, commit, branch or place version (the copy popup);Experiment <name>: use <variant>/ "clear my variant" (Server > Experiments, below); "Ask Claude: ..." puts the query in the Claude draft. Anything that swaps what runs on the server arms on the first Enter or tap and runs on a second one within 4 s. The kernel and the server re-check every request. Core:devtools/commands.ts(pure, Lune-tested byscripts/test-commands.luau). - Server > Experiments (framework 0.7.1): your own variant. Every analytics experiment the game asked about for you, the variant you're in and why (random, forced by the settings, or your pick), a button per variant to pick it for yourself, and Clear; the palette has the same as
Experiment <name>: use <variant>. Devs where the dev-only tools are open to them (dev rules, or owners on a server an owner switched), owners everywhere: the same rule as Modules > State, re-checked by the server and by the analytics engine (analytics/dev-control.ts). It only ever changes your own variant, at most one change a second. The pick is stored in the analytics DataStore (dexp/<UserId>, read at join only for players the rule allows), so it follows you to every server; it wins over the signed settings (stop, settle, weights,players), is stampedforcedon the experiment row (the backend leaves forced players out of the results), and the game sees it the next time it asks for your variant, usually your next join. A pick the game's variants don't have is refused. While the rule doesn't allow you (a dev on a prod server, a revoked role) a stored pick is ignored. - Server TPS (framework 0.4.2, kernel 0.4.2): Server > Status has a TPS line next to Memory and Lua heap: the last minute's average, its slowest second and the physics FPS (
59.9 avg, 52.0 min, physics 60; the warning colour under 50 on average). Older kernels showneeds kernel 0.4.2. The same numbers ride on fleet heartbeats (the explorer's fleet table). - Devtools reach devs only (framework and kernel 0.5.6+): the kernel leaves
devtoolsoff the shared client tree of a framework withdevtools/on-demand.ts.runtime/devtools-loader.tsthen clones the folder, minus the server-only modules, into each dev's PlayerGui (join, each generation, dev status changes), and that client copies it into its own tree and starts it. On older kernels the folder stays in the tree as before. A failure costs a dev the menu, with a warning, and never the generation. - Dev-only tools (framework 0.5.0,
devtools/access.ts): Claude, Logs > Upload, Dex edits on the server, Network packet blocking and the hot-swap sound work under dev rules (private, reserved and Studio servers on a dev branch) for every dev, and on a public server an owner switched to a dev branch (Server > Branch) for owners only. Everywhere else the menu is read-only, with a short reason: "Dev branch only", "Owners only on public servers", "Owner-switched servers only". The rules themselves don't change (TypeTorch.channel, signatures, owner-only rollbacks). - Panes and windows (dev menu): every page shows in a pane with a small header: tap the title to pick another page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them. The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by their title bar and resize from their corner (double-tap the title bar to maximise); their title bar also has Minimise, Dock (back into the main panel) and Close. Windows can be split too. The layout, the windows and each pane's own settings (for example the open nodes of two Modules > State panes) survive swaps; a fresh join starts with one pane. Claude, Server > Branch and the Manage pages open once (picking them again focuses their pane); every other page can be open several times. Panes nobody sees (menu closed, window minimised) stop refreshing, and panes on the same data share one request (Status, Overview, Assets, Network > Stats, server Logs, Packets); State panes share one query budget. On phones and small screens panes only stack (two at most), and windows open maximised with one pane each.
- State explorer (Modules > State, Server | Client): every live module of the running generation (services on the server, controllers on the client) and the persist store. Tap a row to open it: the module's own fields (not its methods), then tables, arrays, Maps and Sets, 100 entries per page (Prev / Next). Each value has a short preview: strings (cut), numbers, booleans, nil, datatypes through
tostring, an Instance as its full path, a Player as its name,function,thread, buffer sizes. Nothing is ever called (raw reads only: no metamethods, no getters); a value that is its own ancestor shows as a cycle. A key filter (open nodes stay listed), Refresh and Auto (every 2 s). Server state comes from the opstate.inspect(rate-limited, size-capped): owners everywhere, other devs where the dev-only tools are open to them, since server state can hold player data. - Logs > Upload: sends the logs shown (Server: this server's log ring; Client: your own client's; Others: the picked player's client) to the dev PC paired in the Claude tab, which saves them under
<repo>/.typetorch/logs/. No Claude run, no prompt quota. Not paired: "Pair in the Claude tab first". - Switch a live server (kernel 0.3.4): owners switch any server and load builds on it, public and prod ones too: Server > Branch, "Switch" on a branch or "Load here" on a build, then the green Confirm. Everyone stays. These are ordinary switches and pins: Reload and Rollback keep working, and going back to prod is Switch on the prod row (its verified head). A public switch lasts for that server's lifetime and is never stored, so new servers still boot the signed prod head. Others see "Join", which moves only them to a reserved server. Manage > Servers "Load a build..." does the same for this server, the ticked servers or a share of one branch (A/B pins), with one status line under the list. A public server an owner switched to a dev branch opens the dev-only tools (Claude too) to owners.
- Owners and devs: two roles (no admins: an older kernel's or typetorch.json's "admin" counts as a dev). The Manage group (Players, Servers, Bans) is for owners only.
- Remote Claude (a dev-only tool: dev rules, or owners on a public server an owner switched to a dev branch): while
typetorch remote-claude(aliastypetorch dev; it runs@typetorch/dev-server) runs on a dev's machine, allowlisted devs prompt Claude Code from the Claude tab. The allowlist is--users; without it the dev-server uses the owners in the game'stypetorch.json. Each dev pairs once by pasting the pairing code printed by typetorch-dev-server; the game server keeps the session URL and the tokens in memory and never sends them to clients. No Roblox secret is needed. (The Studio plugin's Claude tab, with Apply / Deploy to dev / Discard, is a separate client of the same dev-server, started bytypetorch serve --claude; it doesn't go through the framework.) - Remote debug (framework 0.5.0, kernel 0.5.0,
devtools/remote-debug.ts): owners can read a fleet server from the backend's explorer (/servers/<JobId>). The kernel polls the backend, checks the op, the rate and that the caller is an owner, then hands the framework's ops to the handler installed throughkernel.onRemoteDebug. Read-only, with the dev menu's own readers and limits:modules,state(Modules > State queries, rate-limited per caller),assets(the last hot-asset sync),network(per-remote counters, no packet capture),dex.children(8 nodes, 200 rows each at most) anddex.props. There are no edit ops. Older kernels don't have the hook, so nothing is installed. It is part of the devtools server, sodevtools: falseleaves it out too.
Runtime API: TypeTorch
import { TypeTorch } from "@typetorch/framework" works on the server and the client. It describes the running generation and raises events that belong to it: every listener is dropped when the generation stops, so a swap never leaves one behind. Each on* returns a disconnect function, which a trove can own.
ts
// Identity
TypeTorch.artifact; // { id, commit, commitHash, branch, channel, builtAt, seq, assetId }
TypeTorch.generation; // 1, 2, 3... per server (or client)
TypeTorch.branch; TypeTorch.channel; TypeTorch.serverType; // "public" | "private" | "reserved" | "studio"
// Kernel 0.3.9: `channel` is the RULES this server runs under ("prod" on every public server; split data stores by it);
// `branchChannel` is what the branch IS ("prod" for the default branch or one configured prod, else "dev");
// `rules` is the same as `channel`, by name.
TypeTorch.branchChannel; TypeTorch.rules;
TypeTorch.isPinned(); TypeTorch.kernelVersion; TypeTorch.kernelApi; TypeTorch.jobId; TypeTorch.isStudio;
// How this generation started: a branch change starts a new generation.
const start = TypeTorch.startInfo; // { kind: "boot" | "swap", reason, previous?, branchChanged, requestedBy?, loadSeconds? }
if (start.reason === "auto_rollback") warn("the last deploy failed to start");
this.trove.add(TypeTorch.onBranchChanged(({ from, to }) => resetBranchData(from, to)));
// Before this generation stops (runs before every onStop; keep it short)
this.trove.add(TypeTorch.onSwapOut((info) => this.saveRound(info.reason)));
// A swap is coming: show a small hint, hide it if it's called off
this.trove.add(TypeTorch.onUpdatePending((update) => (hint.Visible = !update.cancelled)));
// State that survives swaps (plain data only)
const scores = TypeTorch.persist("scores.v1", () => new Map<number, number>());
// Per player, removed when the player really leaves (same as this.ctx.playerState)
const cooldowns = TypeTorch.playerState("cooldowns.v1", () => ({ lastUse: 0 }));
cooldowns.get(player).lastUse = os.clock();
// The running module of a type (same as Dependency<T>()), from onInit/onStart on
TypeTorch.module<ShopService>().open(player);
TypeTorch.tryModule<ShopService>()?.open(player); // undefined instead of an error
// Dev and roles (server: the kernel decides; client: only the local player, cosmetic)
if (TypeTorch.isOwner(player)) showOwnerPanel(player);
TypeTorch.onPlayerDevChanged((player, info) => setDevTools(player, info.dev));
// Server only
TypeTorch.status(); // uptime, players, memory, history: cheap
TypeTorch.branches(); TypeTorch.artifacts(); // registry reads, cached ~30 s: may yield, don't call per frame
TypeTorch.requestReload(player); // owners only, checked by the kernel
// Logs (the kernel's ring buffer)
TypeTorch.logs(0, 50);
TypeTorch.onLog((entry) => errors.push(entry)); // don't print from inside it
// Hot assets (below): same as hotAsset(...)
const shop = TypeTorch.asset("ui/shop");
// Live values, server only (kernel 0.3.8): the signed settings record's `game` field (`typetorch settings set game.<key> <json>`).
const price = TypeTorch.liveConfig("shop.price", { default: 50, parse: (raw) => (typeIs(raw, "number") ? raw : 50) });
price.get();
this.trove.add(price.onChanged((value) => this.reprice(value)));
// Cross-server, server only. Kernel 0.3.8: game topics over ONE kernel-held MessagingService topic; listeners hear
// prod servers and this branch by default (dev branches never reach prod); publish queues and retries, max 1 KiB.
this.trove.add(TypeTorch.messaging.subscribe<Ban>("1guard", (ban, meta) => applyBan(ban, meta.jobId)));
TypeTorch.messaging.publish("1guard", { kind: "ban", userId }, { to: "prod" });
// The universe's live servers (the roll call; cached per server, yields up to ~3 s when stale) and this server's public fields
TypeTorch.setServerInfo({ region: "eu", vc: true }); // JSON, at most 400 bytes, kept across swaps
const list = TypeTorch.servers(); // [{ jobId, placeVersion, players, maxPlayers, serverType, branch, channel, uptime, here, info }]
// 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.
TypeTorch.runDetached(() => store.StartSessionAsync(`Player_${userId}`)).then((profile) => attach(player, profile));startInfo.reason:boot,deploy,rollback,branch,pin,reload,server_rollback,auto_rollbackorunknown. A client's first generation is alwaysboot.requestedBy(a user id),loadSeconds,stopSecondsandswapSecondsare server-only;swapSecondsappears once the swap has finished.persist(key, init)keeps a table in the kernel's memory for the server's lifetime; every later generation gets the same table back. Store plain data only: tables, arrays, Maps and Sets of strings, numbers, booleans, and Players. Never store functions, class instances, Promises, threads, connections, charm atoms or instances this generation created: they keep the old generation's code alive or are destroyed by its trove. Version the key when the shape changes. Keys starting with__are reserved.onSwapOutruns synchronously before every module'sonStop, so modules can still save intopersist. It doesn't run on server shutdown (kernel 0.3.2 runsonStopthen; on older kernels usegame.BindToClose).Kernel versions:
TypeTorch.featuressays what the running kernel supports. Kernel 0.2.2 adds the start reason and timings, the next artifact inonSwapOut,onUpdatePending, server-sideonPlayerDevChangedandrequestReload. On older kernelsstartInfostill knows boot vs swap, the previous artifact and branch (the framework records them),onBranchChangedstill fires, and the reason of a swap isunknown.messaging(kernel 0.3.8):subscribe(topic, (data, meta) => ...)returns a disconnect;meta={ channel, branch, jobId, serverType, placeVersion, sentAt, self, replayed? };{ from: "all" | "prod" | "branch" }changes who it hears.publish(topic, data, { to })throws on a bad topic, non-JSON data or a message over 1 KiB (with its size), returns false when dropped at once (queue full). Studio loops back; the cloud test publishes nothing; older kernels throw "needs kernel 0.3.8".servers()works on every kernel (0.3.8 holds the roll call topic, so a swap doesn't subscribe again).liveConfig(key, { default, parse? })(kernel 0.3.8, plans/20): readsgame[key]of the signed settings record the kernel verified (signed by both prod keys, so game code can't forge it);get()andonChanged(fn)(only on real changes; generation-scoped). The default without a record or key, whenparsethrows (warned per value), and on older kernels (warned once: "needs kernel 0.3.8"; no ConfigService fallback).TypeTorch.settings()is the whole copy (seq,at,game,analytics,fleet, ...): server only, it holds tokens.Scheduled live events (
liveEvents(), server only): the explorer's Live events page schedules "double cash, Saturday 12:00 to Sunday 23:59 UTC" as a change togamekeys, and the owner signs the schedule with the CLI like any other value (typetorch settings set game.tt.events -, the page gives the exact command). While an event runs (start <= now < endon the server's own clock) its values replace the record'sgame[key]forliveConfig, soget()andonChangedswitch at the start and end times with no message: a server that starts mid-event has it at once, and the event ends on time even when the backend is down. Cancelling = signing the schedule without it. The game only reads its usualliveConfigkeys;parsestill checks an event's value (keep your bounds there):tsconst multiplier = TypeTorch.liveConfig("cash.multiplier", { default: 1, parse: (raw) => (typeIs(raw, "number") && raw >= 1 && raw <= 3 ? raw : 1) }); const events = TypeTorch.liveEvents(); events.active(); // [{ id, name, startsAt, endsAt, set }]: "Double cash ends in 3h" this.trove.add(events.onChanged((active) => this.showBanner(active)));The schedule is
game["tt.events"] = { v: 1, events: [{ id, name, start, end, set: { [key]: JSON } }] }(unix seconds); two events on one key at once: the later start wins. Nothing unsigned reaches servers: the backend only prepares the JSON.runDetached(fn)(kernel 0.3.8, server only): runsfnon a thread the kernel owns, so a deploy's hard stop (which kills this generation's threads mid-call) can't cut it off: for ProfileStore / ProfileService loads, saves and releases. Returns a Promise that settles while this generation runs; after a swap the result is dropped, so a job whose result must survive writes it intopersistitself. The job keeps this generation's closures alive until it ends. Errors reject it and go to the kernel's log with the generation's name (never the health window); at most 256 run at once per server (then it throws); one past 60 s is logged and flagged in Server > Status. Older kernels throw "needs kernel 0.3.8; use the DataHost job queue (Player data guide)": the other option, which works on every kernel.features.runDetachedtells which one this server has.Live debug:
remoteDebugandfleet(framework 0.7.1, kernel 0.5.7, server only;src/live-debug.ts, plans/34): the game's own read-only commands and streams for the backend explorer's server page (/servers/<JobId>), and its own columns on the Fleet page. Import them (import { fleet, remoteDebug } from "@typetorch/framework") or useTypeTorch.remoteDebug/TypeTorch.fleet:ts// A button on the server page (op `game.round_state`); fn(args, caller) runs for an owner, its return is the answer. remoteDebug.command("round_state", () => ({ kind: "kv", items: { round: this.round, alive: this.alive.size() } }), { label: "Round", description: "The current round and who is alive", }); // Sampled at hz (0.2..4) only while someone watches the page; "points" values draw as markers on the live map. remoteDebug.stream("loot", () => ({ points: this.chests.map((c) => ({ x: c.X, y: c.Y, z: c.Z, label: "chest" })) }), { hz: 1, kind: "points", }); // Rides every fleet heartbeat (Fleet page > Columns). Strings at most 64 characters, numbers, booleans. fleet.column("current_map", () => this.mapName, { label: "Map", type: "string" }); fleet.columnsChanged(); // after the map changed: a heartbeat soon (the kernel sends at most one per 10 s)- Commands must be read-only. The kernel can't check what a handler does; the explorer labels them "game-defined". They get remote debug's own rules: owners only, 30 a minute, 10 s each, args at most 2 KB, the answer at most 200 KB of JSON and scrubbed of every secret the kernel knows. Answers the explorer renders:
{ kind: "table", columns: [...], rows: [[...]] },{ kind: "kv", items: { k: v } }, anything else as JSON. - Columns must never carry player names, UserIds or other personal data: they are game state (
current_map,in_round, a round number) and the backend stores them with the server row. - Streams are posted with the kernel's built-in
positions(every player's position, health and state) every second while the page is open; a sample at most 16 KB of JSON (bigger is dropped). - Getters and samplers run on kernel threads under pcall with a short deadline (a column that throws, yields or returns anything else is empty): keep them cheap, don't yield.
- Names
^[a-z][a-z0-9_]{0,31}$; at most 16 columns, 32 commands and 8 streams; labels at most 40 characters, descriptions 200. A bad name or option, a duplicate or one past a limit throws at once; each call returns a function that removes the entry (give it to a trove). Registrations in one frame reach the kernel as one declaration; ones made before the generation is bound (module top level) are sent when it is. - A client throws (server only). Kernels before 0.5.7, places that don't map the kernel's
LiveDebug.luau, Studio and edit mode accept and check everything and send nothing (remoteDebug.supported(),features.liveDebug).
- Commands must be read-only. The kernel can't check what a handler does; the explorer labels them "game-defined". They get remote debug's own rules: owners only, 30 a minute, 10 s each, args at most 2 KB, the answer at most 200 KB of JSON and scrubbed of every secret the kernel knows. Answers the explorer renders:
notifications()(plans/29): native Roblox experience notifications through the TypeTorch backend (backend.url/backend.key, the launch-token keys inbackend.notifyfromtypetorch notify key rotate; no kernel change). The game says why a player should hear from it, the backend decides when and sends, the framework verifies the tap:tsconst notify = TypeTorch.notifications(); // Server: intents (upsert by user + kind + key; offline players by UserId), batched and swap-safe. notify.want(userIdOrPlayer, "gift_new", { key: letterId, until: expiresAt, params: { gift: "5 coins" }, data: `mail:${letterId}` }); notify.drop(userIdOrPlayer, "streak", "20034", "played"); // no key: every key of the kind // The kinds the player switched off in your settings: REPLACES their set ([] = all on), at most 32 names. The backend // skips those intents whoever issued them (pref_off) and keeps them, so switching a kind back on resumes it. notify.setOff(player, ["daily", "quests"]); trove.add(notify.onOpen((player, open) => openMail(player, open.data))); // open: { nid, kind, data, ageSeconds, first } // Client: Roblox's opt-in prompt, once per session (show your own explainer first). Yields until it closes. const result = TypeTorch.notifications().promptOptIn("streak_2"); // "shown" | "unavailable" | "error" | "busy" | "already"Kinds are declared in typetorch.json
notificationsand pushed with the CLI. Intents go to<backend.url>/v1/notify/intentseveryflushSeconds(and at 200); presence (onlineon join and every 3 h,offlineon leave) rides along whilebackend.notifyis set. A join withtt1n.launch data is verified on the server (HMAC, the player's UserId, 14 days; the previous key for 14 days after a rotation):onOpengets only verified data, also for players who joined before the callback connected. Rows (kind = notify):want,drop,optin_check,optin_shown,optin_closed,open,open_bad,played(src/analytics/SCHEMA.md); the join row getsfrom = "notification". Without backend settings (Studio) nothing is posted; rows are still written.feedback()(plans/32, server only): player feedback as a built-in. Collect the answer in your own UI, send it to the server through your own network, and submit it there; the framework checks it and writes one analytics row (kind = feedback,name = submit) on the player's session, which the explorer's Feedback page reads:tsconst feedback = TypeTorch.feedback(); // Optional, once at start: the scale in code (used while the settings record has no game.feedback). feedback.configure({ scale: { id: "faces5", type: "scale", steps: 5, labels: ["BAD", "MEH", "OK", "GOOD", "GREAT"] } }); // In your remote's handler (the arguments come from a client: everything is checked here). const result = feedback.submit(player, { rating, text, topic: "bug", ctx: { level: 12, map: "forest" } }); if (!result.ok) warn(result.reason); // "rate_limited" | "empty" | "bad_rating" | "no_session" | "disabled" // rate_limited also has retryIn: whole seconds until the player may submit again (for "try again in 40 s"). feedback.scale(); // the scale in effect, to send to clients for the UI (undefined while disabled)The config (the settings record's
game.feedback, live:typetorch settings set game.feedback '<json>'; without it the game'sconfigure(...); without that the defaults):jsonc"feedback": { "scale": { "id": "faces5", "type": "scale", "steps": 5, "labels": ["BAD", "MEH", "OK", "GOOD", "GREAT"] }, "maxText": 1000, "rate": { "perMinute": 2, "perDay": 20 } }Field Default Meaning enabledtruefalse(or the whole valuefalse): every submit answersdisabledscale.id"stars5"1-32 of [a-z0-9_], on every row; give a changed scale a new id (old rows keep theirs)scale.type"stars"thumbs(rating 0 down / 1 up),starsorscale(rating 1..steps)scale.steps52..10; thumbs is always 2 scale.labelsnone one per step, lowest first (thumbs: 2, e.g. ["Down", "Up"]); the rated step's label goes on the rowmaxText1000most characters of text kept (1..2000); the row also keeps at most 2000 UTF-8 bytes rate.perMinute,rate.perDay2,20accepted submits per player (in memory, kept across swaps, cleared when the player leaves) An invalid record value disables feedback (warned once per value); an invalid
configurethrows. Text is sanitised (control and invisible characters stripped, whitespace collapsed, trimmed) and capped without splitting a code point;lenkeeps the original length.ratingmust be an integer in the scale (bad_rating); a submit needs a rating or some text (empty);no_sessionwhen noAnalyticsEngineruns or the player isn't in this server. Refused submits don't count against the limits. The row's props (scale,type,steps,r?,norm?,label?,text?,len,topic?,play_s,ctx?) are in src/analytics/SCHEMA.md. On a clientfeedback()throws; in edit mode every submit isdisabled.Edit mode (UI Labs stories, no kernel):
runningis false, identity has defaults,persistkeeps a local table, events never fire, and the server-only reads throw.
Player data
Saving player data is game code: pick any library (ProfileStore, DataStore2, your own). TypeTorch only gives you the pieces that keep it safe across swaps:
- The library lives in the place, outside the payload, and a small place Script requires it first. Its open sessions, autosave loop and shutdown hook then survive every swap (a swap stops the generation's scripts).
- Session handles live in
persist, keyed byUserId.onPlayerAddedreplays everyone after a swap, so it re-attaches to the open session instead of loading again. - Release only on
Players.PlayerRemoving, never inonStoporonSwapOut. - Library calls can't be cut off by a swap: run them as DataHost jobs (any kernel) or with
TypeTorch.runDetached(kernel 0.3.8). - Split store names by channel (
TypeTorch.channel === "prod" ? "PlayerData" : "PlayerData_dev").
Full example with ProfileStore: Player data guide.
Hot assets: hotAsset
Builders mark models and UI templates in the place with the attribute TypeTorchAsset (a key such as "ui/shop"), typetorch assets sync uploads them, and the deploy puts the asset manifest in the artifact. Running servers then get new versions live, with no restart.
ts
import { hotAsset } from "@typetorch/framework"; // or TypeTorch.asset("ui/shop")
// In a controller's onStart: rebuild clones the template.
const shop = hotAsset("ui/shop");
shop.changed(rebuild, this.trove); // a new version went live
rebuild(shop.get());hotAsset(keyOrId, fallback?)works on the server and the client. A number is the Roblox asset id, resolved through the manifest (server) or the live copy'sTypeTorchAssetIdattribute (client).get()returns the live copy now. With none, it returnsfallback(an instance you already hold, such as the template in the place) if it is still parented, elseundefined. Clone it; don't parent or edit it: a new version destroys it.wait(timeout?)isget()that waits for a live copy (forever without a timeout).changed(fn, trove?)callsfn(instance)each time a new copy replaces the live one: a new version, a rollback, or the first copy reaching a client. It returns a disconnect function. Pass the module's trove so the connection ends with it (shop.changed(rebuild, this.trove)orthis.trove.add(shop.changed(rebuild))). Without a trove it ends when the generation stops.keyandversion(the live copy'sTypeTorchAssetVersion, an assetVersionId).
Clients never request anything. They read the CollectionService tag
__typetorch_asset:<key>, and replicated assets arrive through replication. Assets under ServerStorage stay server-only.AssetSync is a server built-in. It runs on every generation start, before any module loads (top-level code included). For each manifest key:
- it keeps the live copy that already has the manifest's version;
- else it adopts the place's own copy, if its
TypeTorchAssetHashmatches, or if the place holds exactly the copies the manifest was exported from: the manifest's optionalplaceVersionis this server's place version, or the place's backup build (ServerStorage.TypeTorchBackup, attributesHotAssetsPlaceVersion/HotAssetsSavedAs) vouches that this place version is a save of that one which changed nothing outside the kernel slots (the CLI's backup refresh); - else it runs
InsertService:LoadAssetVersion(ver)(all loads in parallel), strips any scripts, gives back the CollectionService tags the place's copy has (uploaded versions arrive without them, so a game that finds its UI by tag keeps working), places the copy at its path (creating missing Folders), tags it, then destroys the copy it replaces.
It holds the start for at most 8 s. Loads still running after that keep going, swap in when done and fire
changed. A failed load keeps the old copy (on a new server, the place's copy) and shows under Server > Status > Attention.Hot assets persist across swaps. They aren't in any trove. A swap changes them only when the manifest changes, and a rollback brings back the older versions. Keys the manifest doesn't name are left as they are: AssetSync never deletes builders' content. A key dropped from the manifest keeps its last live copy.
Clones never count. A clone keeps the tag and the attributes, but only the copy whose parent is its
TypeTorchAssetPath(stamped by AssetSync) is the live one. So a template cloned into PlayerGui never shows up inget(), and AssetSync never destroys it.Dev menu: Modules > Assets (Server | Client) lists each key's source (kept, baked, loaded, failed), version, load time and last error. With analytics on, each generation also sends one
tech/assetsrow (the place match and the counts per source;src/analytics/SCHEMA.md).
Analytics: AnalyticsEngine
Optional, and all ours: the game server sends rows to the backend the dev picked (Cloudflare Basin streams, or a self-hosted DuckDB analytics server). Nothing runs until an engine is created: no connections, threads or requests.
ts
import { AnalyticsEngine } from "@typetorch/framework";
// A server module (onInit): the sink comes from the signed settings' `analytics` (kernel 0.3.8).
const analytics = new AnalyticsEngine();
analytics.track(player, "quest_done", { quest: "tutorial" }); // custom
analytics.track(player, "down", { by: "fall" }, { position: true }); // + px, py, pz of the player's character
analytics.step(player, "onboarding", 3, "opened_shop"); // funnels
analytics.purchase(player, { product: 1234, robux: 99, where: "shop" }); // after the receipt is granted
analytics.currency(player, "coins", 50, "round_reward"); // economy in (+) and out (-)
analytics.state("round"); // every player's activity; analytics.state(player, "shop") for one
const variant = analytics.experiment(player, "onboarding", ["short", "long"]); // first = control
// A client controller (onStart): everything goes through the server, never to the internet.
const analytics = new AnalyticsEngine();
analytics.track("opened_map");
analytics.screen("Inventory"); // for UIs that aren't separate ScreenGuis
const variant = analytics.experiment("onboarding", ["short", "long"]); // same answer as the server's- One engine per generation and realm. Every
new AnalyticsEngine()joins the one already running (the first one's options win), so any module can create its own. It stops with the generation; its unsent rows wait inpersistand the next generation sends them. In edit mode (UI Labs) it is inert. - Server calls take the player first (
track(player, ...)); without one an event is server-only (no player id). Client calls are always about the local player and are markedsrc = "client"(a client can lie). The server checks their shape, size (props at most 4 KB) and rate.purchaseandcurrencyare server-only (revenue and the economy are server-authoritative): on the client they warn once and send nothing, and the server refuses them from clients. - Options (all on by default):
sessions(joins, leaves, device, join source, account age bucket, Premium, country, language (locale), the player policy, device trust, friends in the server, first-ever vs returning, days since the last visit),tech(FPS, ping, memory, load time, client and server errors, swaps and rollbacks, leaves within 60 s of a swap),zones(parts or models taggedTTZone, named by aNameattribute or the instance name),screens(ScreenGuis in PlayerGui, GuiObjects taggedTTScreen),recording(the first-ever session in detail),fleet(kernel 0.3.2 heartbeats and deploy reports),identity(one{ pid, uid }row per session to the dev's own server, below).settings(server only) replaces the signed settings'analytics, for tests, Studio and kernels before 0.3.8. - Positions on events (for map layers):
px,py,pzprops (studs, rounded to 0.1) from a character's root part. Per call:track(player, name, props, { position: true })(the event's player; on the client the local player) or{ position: otherPlayer }. Per event name:new AnalyticsEngine({ positions: ["down", "death_final", "round_result", "product"] })(code), or the settings'positions(live, added to the code's list): everytrack,step,purchaseandcurrencyevent with that name gets the event player's position (a purchase is named by itskind,productby default). No character or root part: nothing is added. Props that already havepx,pyorpzare left alone. One property read per such event, nothing per frame. Server-only events (no player) only get one with{ position: somePlayer }. - Every row carries the time (server clock), a random player id (never the UserId), the session, the server (JobId, type, place), the artifact (id, seq, branch, channel), the device, new vs returning, the player's state (
zone:Lobby|screen:Shop|activity:round) and experiment variants. The exact rows:src/analytics/SCHEMA.md. - Experiments:
experiment(...)is deterministic per player and name (they keep their variant in every session), may yield briefly the first time (until the player's id loads), and stamps the variant on the player's later events. The settings turn one off (active: false: everyone gets the first variant), setsweightsor forces avariant, live.players(framework 0.5.3+) forces a variant for single players, keyed by UserId or pid, over everything else (QA, support); a client only ever gets its own entry. The CLI writes these withtypetorch experiment weights | settle | stop | resume | clear | force | unforce(the explorer's Experiments page prepares the command for owners). A dev picks their own variant in the dev menu (Server > Experiments). The experiment row carriesvi(framework 0.7.1+), the variant's position in the game's list, so the backend can check the split. - First-ever session: for new players (a share of them,
recordShare), the client records character and camera about 10 times a second, every input (never while a TextBox or the chat has focus; text boxes only say which box was used), buttons pressed and hovered, screens, prompts and deaths, from the join untilrecordSeconds(the settings, default 60, 10..600) after the first input, and at most 10 minutes in all. Packed into small binary chunks and sent through the server: about 13 KB a minute for an active player (18 KB as stored base64), under 1 KB for an idle one; the per-recording caps (256 KB, 80 chunks) still apply, and 10 minutes of an active player fit inside them. SetrecordSecondsto the length of your first round (e.g. 300) to record it whole. - Never collected: chat or anything typed, usernames and display names (error texts have them replaced). No event row carries a UserId.
- Identities (option
identity, default on): once the pid is known, one row{ pid, uid, t }(the UserId, nothing else) per session goes to the dev's own server only, never into the events: duckdb games in the batch body, Basin games to the backend (the record'sbackendurl +/v1/identity, or theidentity/identityTokensettings). The analytics server keeps pid -> UserId in a deletable table (support lookups, Right to Erasure). - Player ids: DataStore
TypeTorchAnalytics, keyp/<UserId>->{ pid, first, last }: one read per join, a write on the first join and at leave. Deleting the key (Right to Erasure) leaves that player's rows anonymous. - Sending: one queue on the server (10,000 events; over that the oldest are dropped and counted), a flush every
flushSeconds(15) or at 500 rows, at most ~10 HttpService requests a minute, retries with backoff, and a last flush on shutdown (kernel 0.3.2). Delivery is at least once (a swap mid-request sends that batch again).analytics.stats()(server) has the counters;flush()sends soon. - When uploads fail (a stale quick-tunnel URL, the dev PC's server or tunnel down, a wrong token, HttpService off; Roblox reports most network problems as
HttpError: NetFail,DnsResolveorConnectFail): the server backs off 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 about 5 minutes, and a settings change retries at once), keeps the rows, and logs at most ONE line a minute: the reason and the fix (analytics upload failed 5 times in a row: NetFail: the connection broke mid-request. ... Retrying in 80 s., with "(+N more in the last minute)" for the lines it held back) and one line when uploads work again.stats()also hasfailures(in a row),failed(since the server started),lastError,lastStatus(0 = no answer),lastErrorAt/lastOkAt(os.time()),retryIn, and, while failing,reasonandfix. The dev menu shows them: Server > Status has an Analytics line (queued, sent,FAILING x5: ... retry in 80 s) and a Fleet API line (the kernel's sender: sent, failed, last error and its age), and Attention lists the reason and the fix;typetorch doctortests the address and token in the settings record. - The cloud test sends nothing.
typetorch test --cloudboots the payload headless in a Luau Execution task where HttpService works; there the engine (stub kerneltest = true, orworkspace:GetAttribute("TypeTorchTest")) collects as usual but never uploads: no event rows, no identity rows, no HTTP request, so a prod deploy never puts a fake server session into your analytics. - Bounds on what a client can cause: at most 32 experiments per session (each is stamped on every later row), and a client's
expmessages count against its event budget (120 a minute, 5,000 a session). Clients can't sendpurchaseorcurrencyrows; the analytics server's ingest also refuses client-marked ones (older engines) and its revenue queries countpurchaserows the server sent. - Friends in the server (the join's
friends): onePlayers:GetFriendsAsyncper join (at most 10 pages, none when the player is alone) intersected with the players there, not a web call per player.
Settings, framework 0.4.0 / kernel 0.4.0 (plans/21): the record's backend = { url, key, analytics?: { flushSeconds, recordShare, recordSeconds, positions, techEvery, experiments, joinLaunchPrefix, joinData } } is the whole sink: DuckDB at <url>/v1/ingest with the one key (Basin identities to <url>/v1/identity). typetorch backend setup writes it (--flush-seconds, --record-share; writeBackendSettings() in @typetorch/backend runs it, and bun run local too), and typetorch experiment writes experiments. The other dials (recordSeconds, positions, techEvery, joinLaunchPrefix, joinData) have no CLI writer yet: backend setup keeps them when the record has them, and positions can be set in code with the engine option. The old analytics value below is read while backend is missing, and stays the way to a Basin sink. The engine also tells the kernel each player's pid (setAnalyticsId: the kernel's error reports count affected players by pid, never by name) and counts its own DataStore and HTTP requests for the budget view (dev menu Server > Budget: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
Settings (server only, never sent to clients): the signed settings record's analytics field (kernel 0.3.8, plans/20), which typetorch backend setup also writes, derived from backend, for frameworks before 0.4. Every new copy applies live within seconds (the CLI pings servers). A Basin sink has no CLI writer since CLI 0.9 (typetorch settings set analytics points to backend setup). Before kernel 0.3.8 there are no settings unless the game passes new AnalyticsEngine({ settings }):
json
{ "backend": "basin", "events": "https://<stream-id>.ingest.cloudflare.com",
"recordings": "https://<stream-id>.ingest.cloudflare.com", "token": "<write-only token>",
"flushSeconds": 15, "recordShare": 1, "recordSeconds": 60, "positions": ["round_result"], "techEvery": 60,
"experiments": { "onboarding": { "weights": [1, 1] } } }basin: a JSON array per stream (events,recordings),Authorization: Bearer <token>when set (a token with Basin Pipelines Send permission, if the stream requires authentication). Withoutrecordingsnothing is recorded. The streams' schemas must match SCHEMA.md exactly: Basin drops rows that don't, silently.duckdb: onePOST <events>with{"events":[...],"recordings":[...]}, gzip,Authorization: Bearer <token>.Join row settings (also in
backend.analytics):Setting Default Meaning joinData"full""full": the join row's summary props plusjd, a privacy-safe copy of the wholeGetJoinData()(at most 1 KB: launch data up to 200 bytes, place / game ids, the join context as text, the TeleportData's size, type and keys but never its content, Members / referrer / followed user as pids or"other", unknown fields by type; plans/30)."summary": nojd."off": nojdand only the join props from before plans/29 (from,ctx,tp,party, age, prem, country, friends, ret). Anything else warns and means"full". Games that put personal data in launch data should use"summary"joinLaunchPrefixfalsetrue: the summarylaunchkeeps the first 32 characters of the game's own launch data (jd.LaunchDatafollowsjoinData, not this)The token is write-only and never logged. Needs Allow HTTP Requests (Game Settings > Security). No key: the engine collects but keeps only the newest 1,000 rows until settings appear. Removing the key stops sending, live.
Develop
sh
bun install
bun run build # rbxtsc --type package -> out/- Runtime of the transformer:
src/reflection/reflect.tsandmodding.tsare copies of@typetorch/transformer'sruntime-kit/(the source of truth); keep them identical. The framework compiles itself without the transformer (onlyrbxts-transform-debug): it declares macros, which expand in the game's compile. - Versions: bump
src/version.ts(FRAMEWORK_VERSION, shown in uploaded log headers) with package.json, andLATEST_KERNELinsrc/devtools/health.tswhen the framework starts relying on a newer kernel. - Tests (Lune, offline):
scripts/test-*.luau; each file's header has its command (Lune is pinned in the kernel's and the template'srokit.toml).scripts/test-generations.luautakes a game's built payload:cd ../template && bun run payload && lune run ../framework/scripts/test-generations.luau build/payload.rbxmboots two generations in one VM and checks fresh registries, DI and generated guards, then runs the realstartServerwith stub kernels (onStart failures reported to kernel 0.3.2, raised on older ones; onClose), the health lines and the Branch tab / "Load a build" rules (devtools/build-actions.ts). To test framework changes before the template takes them, build the payload from a copy of the template whosenode_modules/@typetorch/framework/outis this repo'sout/.scripts/test-state-inspect.luauchecks the state explorer's core (previews, raw reads only, cycles, paging, Maps/Sets with Instance keys, the depth limit, filters, request validation) and thatsrc/version.tsmatches package.json.scripts/test-layout.luauchecks the dev menu's pane layout core (devtools/layout.ts: splits and their limits, closing, pop out and dock, singleton pages, ratios, focus, and cleaning a remembered layout).scripts/test-modules.luauruns the realstartServer/startClientofout/(with RuntimeLib, Promise, trove and t from node_modules) over several generations that share a persist store:Dependency<T>(),TypeTorch.module/tryModuleand their errors,Lazy<T>(parameters and fields, cycles, start order), networkemitand request timeouts andplayerState(swaps, leaves).scripts/test-flamework-compat.luau(same harness) drivescreateFlameworkCompatthrough each generation's dispatcher: connect (one thread per call, guards, Disconnect, troves), fire / except / broadcast / the call shorthand, setCallback with values and Promises, predict, a swap, and the client side (invoke, invokeWithTimeout,createClient({ defaultTimeout })).scripts/test-analytics.luauchecks the analytics engine's pure parts (experiment assignment, settings, the queue and HTTP budget, tt-rec-1, the sink request bodies, the failure help);test-analytics-server.luauandtest-analytics-client.luaurun the compiled server and client cores against mocked services (sessions, intake, retries, a NetFail streak and its one log line a minute, a swap with a request in flight, shutdown; the recorder, screens, batching).scripts/test-net-health.luauchecks the dev menu's Fleet API / Analytics status lines and Attention issues. The others, each on the compiledout/:test-hot-assets(manifest parser, AssetSync decisions incl. the backup vouch, live-copy rules),test-messaging(messaging and the server list),test-settings(liveConfigand the settings lines),test-live-events(scheduled live events on a fake clock),test-detached(runDetached),test-live-debug(remoteDebug/fleet: names, limits, the client, older kernels, one declaration per frame, the handlers, columnsChanged),test-dev-access(the dev-only tools rule and every gate through it),test-remote-debug(the remote debug ops and their limits),test-net-inspect(packet capture),test-explorer-core(Dex),test-toolbox-sanitize(the Creator Store sanitizer),test-sha256(SHA-256 / HMAC in util and the pairing-code fingerprint),test-notifications(the launch token with the contract's shared vector, intent checks, the queue: batches of 200, rev order, a swap's handover),test-notify-server(the notifications server against mocked services),test-analytics-join(the join row's metadata; no other player's UserId),test-feedback(feedback(): the scale config, rating normalisation, text sanitising and caps with multi-byte text, rate limits, the row's shape; run from framework/:lune run scripts/test-feedback.luau out/),test-commands(the command palette's core and its branch / build rules) andtest-devtools-loader(who gets the devtools copy and when a client starts it).node scripts/check-self-calls.mjs outflags compiled functions that call themselves on their first lines, andnode scripts/check-devtools-split.mjs outchecks that a non-dev client's tree never reachesdevtoolsand that a dev's copy holds everything its client imports (both afterbun run build). - Package contents: only
out/(no.tsbuildinfo),README.mdandLICENSE; check withbun pm pack --dry-run. - Explorer class icons:
assets/class-icons.png(kept locally, not in the repo: it is Roblox's own texture) is the client'scontent/textures/ClassImages.PNG(a 2352x16 strip) repacked into a 32-column grid, because live clients downscale textures wider than 1024 px:ffmpeg -i ClassImages.PNG -vf "untile=147x1,tile=32x5:color=0x00000000" class-icons.png. It is uploaded as imagerbxassetid://97389585475400;scripts/gen-explorer-icons.tsgenerates the index table.
Release
Publishing is tag-only, from CI (.github/workflows/publish.yml), with npm trusted publishing (OIDC, provenance):
sh
# first commit src/version.ts set to the new version (npm version needs a clean tree)
npm version <x.y.z> -m "release %s"
git push origin main --follow-tagsThe workflow runs on a v* tag, checks that the tag matches package.json and that the version isn't on npm yet, then npm publish --provenance (which runs prepublishOnly: a clean rbxtsc --type package into out/) and creates the GitHub release with generated notes. A prerelease version (x.y.z-next.0) goes to the next dist-tag and a GitHub prerelease; anything else to latest. Trusted publishing is set on npmjs.com (@typetorch/framework > Settings > Trusted publishing: this repo, workflow publish.yml, environment npm); an NPM_TOKEN repo secret is the fallback. Don't run npm publish by hand. Games pick the new version up with bun update @typetorch/framework and their next deploy, since the framework ships inside the payload.
MIT licensed.