This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a Roblox game project using roblox-ts (TypeScript for Roblox) and Flamework (a TypeScript framework for Roblox game architecture).
# Install dependencies
bun install
# Build (compile TypeScript to Luau)
bun run build
# Watch mode (hot reload during development)
bun run watch
# Lint and format
bun run lintAfter building, use the Rojo VS Code extension to sync changes to Roblox Studio.
src/
├── client/ # Client-side code (StarterPlayerScripts)
│ ├── components/ # Client Flamework components
│ ├── controllers/ # Flamework controllers (singletons)
│ └── runtime.client.ts
├── server/ # Server-side code (ServerScriptService)
│ ├── components/ # Server Flamework components
│ ├── services/ # Flamework services (singletons)
│ └── runtime.server.ts
└── shared/ # Shared code (ReplicatedStorage)
├── components/ # Shared Flamework components
└── network.ts # Flamework networking definitions
- Services (
@Service) - Server-side singletons for generic, reusable capabilities - Controllers (
@Controller) - Client-side singletons for input/UI - Components (
@Component) - Tag-based behavior attached to instances, self-contained orchestrators - Networking - Type-safe RemoteEvents/Functions, scoped per feature/component
This project follows a composable architecture pattern. This is the guiding principle for all system design.
Components are self-contained orchestrators:
- A component owns ALL logic for its feature (state management, rules, events, etc.)
- Components reach out to generic services only for capabilities they don't own
- Components should NOT delegate feature logic to feature-specific services
Services are generic, reusable capabilities:
- Services should be domain-agnostic (e.g.,
TeleportService, notLobbyService) - A service shouldn't know about specific features that use it
- Services provide "verbs" that any system can use
| Layer | Location | Purpose |
|---|---|---|
| Core | src/shared/core/ |
Game-agnostic reusable modules (Health, Inventory, Damageable, ResourceProducer, etc.) |
| Game | src/shared/game/ |
Project-specific modules that compose Core modules |
Core modules should never reference Game modules. Game modules compose and extend Core modules for project-specific behavior.
❌ Wrong (tightly coupled):
LobbyComponent → LobbyService (lobby-specific)
ShopComponent → ShopService (shop-specific)
QuestComponent → QuestService (quest-specific)
✅ Correct (composable):
LobbyComponent (self-contained)
├── Player tracking, countdown, rules
└── When ready → TeleportService.teleportGroup(players, destination)
ShopComponent (self-contained)
├── UI state, item display, purchase validation
└── When purchasing → CurrencyService.deduct(player, amount)
QuestComponent (self-contained)
├── Progress tracking, completion checks
└── When complete → RewardService.grant(player, rewards)
- Components are drop-in - Add the component, it works
- Services are reusable -
TeleportServiceworks for lobbies, parties, dungeons, etc. - Easier to test - Mock generic services, test component logic in isolation
- Clearer ownership - "Lobby does lobby things, teleport service teleports"
Components communicate through events/signals, not direct method calls on each other:
1. Direct Callbacks (local, same context) Use callback functions or signal instances when modules share a context or have a natural parent-child relationship.
2. Global Event Bus (cross-system, decoupled) Use for communication across unrelated systems (e.g., "player died" → UI death screen, scoring system, respawn timer all react independently).
Rule of thumb: If you'd need to hunt for a reference just to talk to something, use the event bus instead.
Modules expose public methods or raise signals. No module should reach into another module and set its fields directly. This keeps ownership clear and makes networked state easier to reason about.
Each feature/component owns its networking:
// src/shared/network/lobby.ts - Lobby-specific events
export const LobbyEvents = Networking.createEvent<
{ leave: () => void },
{ playerJoined: (), playerLeft: (), countdown: (seconds: number) => void }
>();NOT a monolithic network.ts with all events mixed together.
When designing a new system, ask:
| Question | If Yes → |
|---|---|
| Is this logic specific to one feature? | Put it in the component |
| Could multiple features use this? | Make it a generic service |
| Does the service name include a feature name? | Rename to be generic |
| Does the component delegate its core logic? | Refactor to be self-contained |
Both runtime.client.ts and runtime.server.ts use Flamework.addPaths() to register components/services/controllers, then call Flamework.ignite() to start.
Network events and functions are defined in src/shared/network.ts using Networking.createEvent<>() and Networking.createFunction<>(). Client and server import from src/client/network.ts and src/server/network.ts respectively to get typed event handlers.
- Uses Biome for formatting (tabs, double quotes)
- Uses ESLint with roblox-ts plugin for linting
- Unused variables should be prefixed with
_ - JSX uses Roact syntax (
Roact.createElement) - One module per file. File name matches the primary export.
- Keep modules small. If a module is doing two distinct things, split it.
Use @rbxts/maid for managing connection cleanup. Never manually track RBXScriptConnection objects.
import Maid from "@rbxts/maid";
@Component({ tag: "Example" })
export class ExampleComponent extends BaseComponent<Attrs, Model> {
private readonly maid = new Maid();
onStart() {
// Add connections to maid
this.maid.GiveTask(
someEvent.Connect(() => { ... })
);
}
destroy() {
this.maid.DoCleaning();
super.destroy();
}
}When tracking connections for dynamic entities (players, NPCs, etc.), use a Map of Maids:
private readonly maid = new Maid(); // Component lifetime
private readonly playerMaids = new Map<Player, Maid>(); // Per-player
private onPlayerJoin(player: Player) {
const playerMaid = new Maid();
this.playerMaids.set(player, playerMaid);
playerMaid.GiveTask(
player.CharacterRemoving.Connect(() => { ... })
);
}
private onPlayerLeave(player: Player) {
const playerMaid = this.playerMaids.get(player);
if (playerMaid) {
playerMaid.DoCleaning();
this.playerMaids.delete(player);
}
}
destroy() {
for (const [_, playerMaid] of this.playerMaids) {
playerMaid.DoCleaning();
}
this.maid.DoCleaning();
super.destroy();
}useEffect(() => {
const maid = new Maid();
maid.GiveTask(Events.something.connect(() => { ... }));
return () => maid.DoCleaning();
}, []);