Skip to content

Roblox-TS: Patterns

Platform lifecycle pattern

Our custom framework uses explicit lifecycle methods via the Application bootstrapper.

Service interface

// packages/core/src/application.ts
export interface Service {
  /**
   * Called synchronously during boot.
   * Order-independent between services.
   * Use for: setting up events, signal connections.
   * DO NOT YIELD here.
   */
  onInit?(): void;

  /**
   * Called asynchronously after all services have initialized.
   * Safe to call other services.
   * Use for: starting game loops, data loading.
   */
  onStart?(): void;
}

Boot sequence (server)

// games/starter/src/server/main.server.ts
import { Application } from "@rbx/core";

// Import services to ensure side-effects run (loading into Application)
import "./services/ActionService";
import "./services/HandshakeService";

Application.boot();

Controller interface (client)

// Similar pattern for client-side controllers
export interface Controller {
  onInit?(): void;
  onStart?(): void;
}

Example service

We prefer Singletons defined as objects over classes for simpler dependency management in Roblox-TS.

// games/starter/src/server/services/ActionService.ts
import { Service, createLogger } from "@rbx/core";

const logger = createLogger("ActionService");

export const ActionService: Service = {
  onInit() {
    logger.info("Initializing");
    // Register type-safe event listeners here
  },

  onStart() {
    logger.info("Starting");
    // Start main logic loops here
  },
};

Service factory pattern

For cross-game reusable logic, we extract services into factory functions in shared packages. Each game passes its own config (DataStore name, callbacks, etc.) and re-exports the resulting Service.

This eliminates copy-paste duplication while keeping each game in full control of its configuration.

Writing a factory (package side)

// packages/moderation/src/create-moderation-enforcement-service.ts
import { Service, createLogger } from "@rbx/core";
import { getModeration } from "./service";

export interface ModerationEnforcementConfig {
  datastoreName: string;
  onPlayerAdded: (callback: (player: Player) => void) => void;
}

export interface ModerationEnforcementHandle {
  Service: Service;
}

export function createModerationEnforcementService(
  config: ModerationEnforcementConfig
): ModerationEnforcementHandle {
  const logger = createLogger("ModerationEnforcementService");

  const Service: Service = {
    onInit() {
      const moderation = getModeration(config.datastoreName);
      config.onPlayerAdded((player) => {
        // enforce bans, apply mute attributes …
      });
      logger.info("Moderation enforcement enabled");
    },
  };

  return { Service };
}

Consuming a factory (game side)

// games/starter/src/server/services/ModerationEnforcementService.ts
import { createModerationEnforcementService } from "@rbx/moderation";
import { PlayerLifecycleService } from "./PlayerLifecycleService";

const handle = createModerationEnforcementService({
  datastoreName: "StarterModeration",
  onPlayerAdded: (cb) => PlayerLifecycleService.onPlayerAdded(cb),
});

export const ModerationEnforcementService = handle.Service;

Guidelines

Guideline Rationale
One factory per service concern Keeps factories focused and composable
Accept callbacks for cross-service deps Avoids the factory importing game-local code
Return a Handle object Allows exposing getters/helpers alongside the Service
Export the Service by the same name Existing main.server.ts registrations stay unchanged

Networking pattern

  • All remotes are defined in packages/net/src/remotes.ts.
  • RemoteService (Server) and RemoteController (Client) act as the gateways.
  • Validation is explicit and typed using @rbxts/t.

Remote registration

// Server Service
import { RemoteService } from "./RemoteService";
import { validateMyPayload, ok, err } from "@rbx/net";

RemoteService.Remotes.MyRemote.OnServerInvoke = (player, payload) => {
  const result = validateMyPayload(payload);
  if (!result.ok) {
    return err(ErrorCode.InvalidPayload); // Fast fail
  }

  // Safe to use result.value
  return ok(logic(result.value));
};

Client calling

// Client Controller
import { RemoteController } from "./RemoteController";
import { ok } from "@rbx/net";

const result = RemoteController.Remotes.MyRemote.InvokeServer(payload);
if (result.ok) {
  print("Success:", result.value);
}

Security-by-default

  • Default deny: if a remote is not in the registry, it does not exist.
  • Validation and rate limiting are middleware, not ad-hoc checks.
  • Never trust client-sent values without validation and bounds checking.

Validation pattern

// Always validate at the boundary
function handleRemote(player: Player, rawPayload: unknown) {
  // Step 1: Schema validation
  const validation = validate(schema, rawPayload);
  if (!validation.ok) {
    return { ok: false, code: ErrorCode.InvalidPayload };
  }

  // Step 2: Bounds validation
  const payload = validation.value;
  if (payload.someNumber < 0 || payload.someNumber > 100) {
    return { ok: false, code: ErrorCode.InvalidPayload };
  }

  // Step 3: State validation
  if (!canPlayerDoThis(player)) {
    return { ok: false, code: ErrorCode.InvalidState };
  }

  // Step 4: Execute
  return executeAction(player, payload);
}

UI kit usage

  • Central design tokens (spacing, typography, color).
  • Device-safe layout rules:
  • safe areas
  • scalable text
  • controller navigation

Competitive PvP patterns

  • Client prediction for feel.
  • Server arbitration for outcomes.
  • Limited lag compensation (bounded rewind window, simplified hitboxes).
  • See docs/architecture/hit-validation.md for details.

Error handling pattern

Never throw across trust boundaries. Always return Result<T>:

// Good
function processIntent(payload: unknown): Result<Output> {
  if (!isValid(payload)) {
    return { ok: false, code: ErrorCode.InvalidPayload };
  }
  return { ok: true, value: doThing(payload) };
}

// Bad - never do this
function processIntent(payload: unknown): Output {
  if (!isValid(payload)) {
    throw new Error("Invalid payload"); // Exploiter sees this!
  }
  return doThing(payload);
}

Cleanup pattern

Always clean up connections and listeners:

// packages/core/src/cleanup.ts
export class Janitor {
  private items: (() => void)[] = [];

  add(cleanup: () => void) {
    this.items.push(cleanup);
  }

  addConnection(connection: RBXScriptConnection) {
    this.items.push(() => connection.Disconnect());
  }

  destroy() {
    for (const cleanup of this.items) {
      cleanup();
    }
    this.items = [];
  }
}

// Usage in a service
class MyService implements Service {
  private janitor = new Janitor();

  init() {
    this.janitor.addConnection(Players.PlayerAdded.Connect((player) => this.onPlayerAdded(player)));
  }

  destroy() {
    this.janitor.destroy();
  }
}