Skip to content

Project structure

KILLSCRIPT ultimately loads one client script and, for Reflex, one server script. Your source does not need to imitate that limitation. Static imports are bundled, so files can follow feature boundaries instead of generated-file boundaries.

src/
├── client/
│ ├── main.ts
│ ├── controls.ts
│ ├── network.ts
│ └── features/
│ ├── module-toggle.ts
│ └── status-hud.ts
├── server/ # Reflex only
│ ├── main.ts
│ ├── network.ts
│ └── features/
│ └── state-control.ts
└── shared/
├── messages.ts
└── settings.ts
public/
├── images/
├── sounds/
└── ui/
killscript.config.json

This is a direction, not a required empty-folder template. Create a directory when the module actually has code that belongs there.

Keep main.ts intentionally boring:

src/client/main.ts
import "./features/module-toggle";
import "./features/status-hud";

An imported feature registers its module-lifetime handlers at the top level:

src/client/features/module-toggle.ts
import { controls } from "../controls";
let enabled = false;
controls.toggle.onPressed(() => {
enabled = !enabled;
NotificationController.ShowHint(
enabled ? "Module enabled" : "Module disabled",
1.5,
);
});

Static modules execute once when the bundle starts. You do not need an initSomething() function whose only job is to register top-level callbacks. Export functions when another feature genuinely needs to call a behavior, not as ceremony around every file.

  • client/ may use cameras, controls, UI, audio and other client globals;
  • server/ may use Reflex globals and make authoritative decisions;
  • shared/ should contain plain types, constants and data transformations that do not assume either runtime API;
  • public/ contains files copied into the module package;
  • settings may be shared when both contexts need the same identical definition.

The compiler rejects a client implementation import from server/ and the reverse. This catches an architecture mistake before generated Lua reaches the game.

Settings, controls and network declarations may appear in any reachable file, but must initialize a top-level const:

// Valid: the setting belongs to this feature.
const settings = defineSettings("statusHud", {
visible: true,
});
// Rejected: generated JSON would depend on runtime control flow.
function registerLater(): void {
const settings = defineSettings("statusHud", { visible: true });
}

Use a shared declaration file when several features import the same handle; otherwise colocating a declaration with its only consumer is clearer.

Only files reachable from an entry are bundled:

src/client/main.ts ──▶ scripts/main.lua
src/server/main.ts ──▶ scripts/server.lua
public/** ──▶ copied module assets

Relative imports omit the extension. Do not call require() manually; the generated bundle contains its own static module loader and does not depend on the game’s nested path resolution.

Continue with Patterns for toggleable features, resource ownership and shared state.