Skip to content

Patterns

Good module architecture is mostly about answering two questions:

  1. which feature owns this state or callback?
  2. when does the owned resource stop existing?

The compiler already handles bundling and name scoping. You do not need a framework, dependency container or one giant main.ts.

A small always-on feature can register itself at the top level:

client/features/damage-hint.ts
import { logger } from "@killscript/sdk/client";
const log = logger("DamageHint");
Agents.OnLocalPlayerReceivedDamage((event) => {
NotificationController.ShowHint(
`Received ${event.Damage} damage`,
1.5,
);
log.debug(`Damage tick: ${event.DamageTick}`);
});

client/main.ts only imports it. State used solely by this feature stays in the same file and is not exported.

Anything created on enable must be released on disable. A scope makes the ownership visible:

client/features/position-marker.ts
import {
Keyboard,
type ClientScope,
defineControls,
scheduler,
scope,
visuals,
} from "@killscript/sdk/client";
const controls = defineControls("positionMarker", {
toggle: Keyboard.F6,
});
let active: ClientScope | undefined;
controls.toggle.onPressed(() => {
if (active !== undefined) {
active.dispose();
active = undefined;
return;
}
const local = Agents.GetLocalAgent();
if (local === undefined) return;
const next = scope();
const marker = next.world(visuals.overlay({
position: local.Movement.Position,
size: new Vector3(0.6, 0.02, 0.6),
color: new Color(0, 1, 1, 0.5),
}));
if (marker === undefined) {
next.dispose();
return;
}
next.using(scheduler.every(0.25, () => {
const current = Agents.GetLocalAgent();
if (current !== undefined) {
marker.SetPosition(current.Movement.Position);
}
}));
active = next;
});

The variable answers whether the feature is active. The scope answers what must be cleaned when it stops. See Feature lifecycle for disposal order and server scopes.

Good shared files contain data that means the same thing in both runtimes:

shared/messages.ts
export interface MarkerState {
readonly enabled: boolean;
readonly x: number;
readonly y: number;
readonly z: number;
}

Client UI state remains client-owned. Server decision state remains server-owned. Serialize plain values through a typed network channel instead of trying to share an Agent, callback or mutable module variable.

For most features, these forms are enough:

control → feature state → client presentation
control → network intent → Reflex state → network result → presentation
game event → feature state → presentation/log

Avoid a global event bus when a direct function call or one typed network message shows the relationship more clearly.

  • keep entry files as lists of enabled feature imports;
  • register top-level settings and controls near their owner;
  • keep a variable private until another file genuinely needs it;
  • use a scope for subscriptions, timers and visuals created dynamically;
  • make client/Reflex ownership obvious from the directory and SDK import;
  • use raw typed game globals when no SDK helper improves the call;
  • remove a feature by deleting its one entry import and its owned files.

When a file starts handling several unrelated controls, several independent states or multiple cleanup lifecycles, split it by behavior rather than by arbitrary categories such as “all callbacks” and “all variables.”