Skip to content
Back to home page

Architecture

How I build systems

I build Roblox games the way backend services are built: every system has an explicit boot order, every piece of state has one owner, and player data has to survive a server dying mid-save. This page covers how I structure every project, the data layer underneath it, and one problem where the numbers mattered.

Services, controllers, components

I structure every game the same way: services on the server, controllers on the client, and components bound to instances, each with a strict init → start lifecycle. In roblox-ts projects that's Flamework, and in Luau projects it's Knit. Before using either, I wrote my own framework with the same shape (new → init → start), so I know what these frameworks do under the hood, not just how to call them.

Whichever one runs underneath, the rules are the same:

  1. init: set up internal state and wire up dependencies. Nothing is running yet. In Flamework, onInit holds up the boot until it returns, so I keep it to setup and wiring; anything that yields waits for onStart.
  2. start: connect events, start loops, begin work. Runs only after every system has initialized.

Anything another system depends on is created in init and only used in start, so no system ever reads a value that doesn't exist yet.

Why I care about boot order. My first framework taught me this the hard way. The HUD controller had to reach every menu frame so its buttons could open them, so it had to boot before everything else. Whichever system initialized first won the race, and I rebuilt the architecture four times chasing it. The version that held up had three parts:

  • Separate phases: construct everything, then initialize everything, then start everything.
  • Waiting instead of crashing: a system that reached for another one that hadn't finished the current phase yielded until it had, instead of hitting nil.
  • Explicit ordering: each phase had its own optional order, so a system could be pinned first, second, second-to-last or last, while the rest loaded in any order.

Flamework solves the same problem with constructor dependency injection, so the race can't happen in the first place.

  • Flamework gives me that dependency injection plus type-safe networking: remote payloads are checked against their TypeScript types at runtime, so a malformed request from a client is rejected before my code sees it. Components bind by CollectionService tag, with their attributes guarded the same way.
  • Knit is what I use when a project is pure Luau or the codebase is already built on it: services expose a Client table for remotes, and controllers fetch the services they need.

+1 Aura Battles runs on Flamework: 46 interlinked systems, built solo over about a month. It started on my own framework and moved to Flamework because it handled dependencies far better. It's now in live ops at 500–1,000 concurrent players.

starter-pack-service.ts
// src/systems/starter-packs/server/services/starter-pack-service.ts
import type { OnInit } from "@flamework/core";
import { Service } from "@flamework/core";
import { STARTER_PACK_KEYS, STARTER_PACKS } from "game/config/starter-packs";
import type { DataService } from "core/server/services/data-service";
import type { ProductService } from "core/server/services/product-service";

/**
	The starter packs (see STARTER_PACKS): each grants its bundle and marks itself bought, in the
	purchase's transaction. The client offers them during the player's first minutes of play and
	hides the ones bought. A receipt grants even past the offer (the player paid), but only once:
	a second one (a direct prompt, another server) settles without the bundle, like an owned pass
*/
@Service()
export class StarterPackService implements OnInit {
	public constructor(
		private readonly productService: ProductService,
		private readonly dataService: DataService,
	) {}

	public onInit(): void {
		for (const key of STARTER_PACK_KEYS) {
			const pack = STARTER_PACKS[key];
			this.productService.registerProduct(
				pack.Product,
				(player) => {
					const purchases = this.dataService.getDocument(player)?.read().Purchases;
					if (!purchases) return undefined;
					const markBought = { path: `Purchases.${pack.BoughtFlag}` as const, updater: () => true };
					return purchases[pack.BoughtFlag]
						? { writes: [markBought] }
						: { bundle: pack.Reward, writes: [markBought] };
				},
				{
					once: true,
					check: (player) =>
						this.dataService.getDocument(player)?.read().Purchases[pack.BoughtFlag]
							? `You already bought the ${pack.DisplayName}`
							: undefined,
				},
			);
		}
	}
}

Data that survives a server dying

Geode, my open-source data library, gives each Roblox data service the one job it's actually good at:

  • DataStore is the durable record, and it holds ownership too. A session owns a profile through a lease stored inside the record itself, so taking, renewing and releasing it happen in the same UpdateAsync that reads and writes the data, and ownership and data can't drift apart. A server autosaves every 60 seconds, a lease nobody renews goes stale after 5 minutes, and a crashed server can't overwrite a newer owner because every write is checked against the lease. Each write also carries a session marker, so a retry after Roblox's "failed but actually saved" case recognizes its own write instead of losing the session or applying it twice. Purchases, mail and erasure ride in the same write as the data, so they land exactly once.
  • MessagingService carries signals between servers: the request to release a profile when the player has arrived somewhere else, edits and rollbacks sent to whichever server holds a profile, and invalidations for cached shared data. A message only ever says "try now"; the lease in the DataStore still decides. That's how a server hop takes about 0.7 s.
  • MemoryStore holds fast, short-lived state: a handoff key that confirms a release when a message gets lost, plus opt-in building blocks: presence, locks with fencing tokens, cross-server counters and rate limiters, job queues, matchmaking (tickets in sorted maps, with one elected matcher per queue) and daily and weekly leaderboards. Nothing durable ever lives only in MemoryStore.

Every write also passes through guards (type, range, delta, rate, relation and custom checks) that judge the final change before it lands, with the strongest verdict winning: kick, reject, flag or warn. Changes are reactive, and state syncs from server to client (built in, or swapped for Charm).

The trade-off. Geode is bigger than a profile library: there's more API to learn, and datatypes like Vector3 need a schema to be saved. Its MemoryStore features cost shared quota, so they're opt-in and report their estimated request rate. It's also new, with live-server benchmarks but no years of production use. If you only need session-locked player saves and want the most battle-tested option, ProfileStore is the right call. Geode is for games that also want validation, cross-server features and tooling in one consistent system.

Case study: a 5-second server hop

Problem. When a player hops servers, the new server can't load their profile until the old one has saved and released its lease. In Geode 0.5 that took a median 4.95 s on live servers, even though the old server released within half a second.

Cause. The new server checks the lease with UpdateAsync. After the release was announced, its retries came back in under 10 ms, still showing the old lease, until about 4 s after the first try. The timings fit the server answering from its own cached copy of the key, a cache Roblox documents only for GetAsync. A hop cost 4 UpdateAsync and 1 GetAsync.

Fix. In 0.6.0, once the release is announced, the new server reads the record once with UseCache = false. That read reaches the backend and refreshes the copy, so the next UpdateAsync sees the release and takes the lease.

Result. The median hop dropped to 0.70 s (0.48–0.88), with 2 UpdateAsync and 1 GetAsync past the cache, against 5.50 s for ProfileStore and 7.30 s for ProfileService. The final save was seen in 15 of 15 samples. Making the mailbox opt-in in the same release wasn't the speedup: with mailbox = true, hops take 0.68 s.

Trade-off. The fix relies on cache behavior I observed, not behavior Roblox documents. If it changes, hops slow down but stay safe, because UpdateAsync still decides who holds the lease. And if the release message is lost, the new server falls back to the handoff key, checked every 2 s, so a rare hop can take up to about 2 s longer.

What I hold to

  • The server never trusts the client. Every remote is validated on the server before it touches state: Flamework checks payloads against their types at runtime, and anything a player can spam gets its own rate-limit bucket, so an auto-clicker can't drain the same budget as a purchase. Geode's guards apply the same idea to data: every write is judged before it lands.
  • One owner per piece of state. Player data lives in one DataService; everything else reads it through path-based selectors or subscribes to Charm atoms, and only the owner writes. When a bug touches data, there's exactly one place to look.
  • Anything involving money or data is idempotent. A purchase is granted once even if the receipt arrives twice or on another server, and Geode marks every write so a "failed but actually saved" retry recognizes itself instead of applying twice.
  • Measure before claiming. Geode's benchmarks run on live Roblox servers, and the method and raw data are public, including the results where Geode doesn't win.
  • Readable beats clever. Code outlives the sprint it was written in, and usually its author's memory of it. I'd rather write the obvious version and name things well than save ten lines nobody can follow, which is also why the codebases I hand over are ones other people can run live ops on.