Modules: import and export

PixelScript uses ES6 style modules to share code between scripts. Exports make functions, classes and values available; imports pull them in.

Named exports only. Default exports are not supported.

Exporting

// utils/messages.js
export function success(message) {
  return `<green>✓ <white>${message}`;
}

export function error(message) {
  return `<red>✗ <white>${message}`;
}
// config/constants.js
export const SERVER_NAME = 'PSCraft';
export const MAX_PLAYERS = 100;
// utils/economy.js
export class Economy {
  constructor() {
    this.accounts = new Map();
  }

  getBalance(uuid) {
    return this.accounts.get(uuid) || 0;
  }
}

You can also collect exports at the bottom of the file, which reads well for modules with a lot of small helpers:

function formatDuration(minutes) { /* ... */ }
function hasExpired(expiryTime) { /* ... */ }

export { formatDuration, hasExpired };

Importing

import { success, error } from '@/utils/messages';
import { SERVER_NAME } from '@/config/constants';
import { Economy } from './economy';

registerListener($.PlayerJoinEvent, (event) => {
  event.getPlayer().sendRichMessage(success(`Welcome to ${SERVER_NAME}`));
});

Import renaming (import { func as myFunc }) and wildcard imports (import * as module) are not supported.

Path resolution

This is where most confusion lives, so here is the complete set of rules.

Form Resolves relative to Example
@/path/to/file The scripts root @/utils/messages
/path/to/file The scripts root /utils/messages
./file The importing script’s directory ./economy
../file The importing script’s directory ../chat/messages
path/to/file The importing script’s directory sys/shop

Two things worth internalising:

  • The .js extension is optional. If you omit it the runtime tries .js first, then .ts. Both './economy' and './economy.js' work; pick one style and stick to it.
  • Use @/ with the slash. The root alias must be written @/utils/messages. Bare @utils/messages does not resolve correctly.

@/ is strongly recommended for anything more than one directory away. It survives moving files around, and it stops you from writing '../../../../patches/chat/messages', which nobody can read.

The generated tsconfig.json already maps @/* to the scripts root, so your IDE follows these imports and autocompletes across them.

Lifecycle

An imported script is a module in the load tree. Its lifecycle differs from a watched script in ways that matter.

One instance, shared. Two scripts importing the same module share a single evaluated instance, including its top-level state. That makes an exported instance a genuine runtime singleton:

// utils/economy.js
class EconomyManager {
  constructor() {
    this.accounts = new Map();
  }
  /* ... */
}

// Every importer gets this exact object
export const economy = new EconomyManager();

Reloads cascade upward. Editing a module reloads it, then every script that imports it, then their children. A change to a module imported by fifty scripts reloads fifty scripts.

Unloads when the last importer goes. A module stays loaded as long as anyone depends on it.

Isolate modules

Name a file something.isolate.js and it gets a fresh instance for every importer instead of being shared. Use this when a module holds per-consumer state that must not be shared. It is the exception, not the default, and it makes Script.getCaller() ambiguous, so reach for it deliberately.

Avoiding reload cascades

The size of your reload tree is a design decision. Keep modules small and focused:

BAD                                     GOOD
utils/common.js                         utils/messages.js
  (100 helpers, imported by 50 files)   utils/permissions.js
  → one edit reloads 50 scripts         utils/formatting.js
                                          → an edit reloads only what uses it

The other half of the rule: features should be watched, not imported. If a script’s job is to register commands and listeners, and it exports nothing anyone needs, load it with Watcher.watch(). Watchers are barriers, so a reload stops there.

// init-common.js

// Watched: reload barrier. Editing init-common.js does not reload this.
Watcher.watch('features/economy/init.js');
// features/economy/init.js

// Imported: editing constants.js reloads this file too.
import { SERVER_NAME } from '@/config/constants';

Circular imports

Circular imports are detected and rejected at load time with a readable trace (a.js > b.js > a.js). If you hit one, the usual fix is to extract the shared piece into a third module that both sides import.

Worked example: a database wrapper

// utils/player-data.js
export function loadPlayerData(uuid, callback) {
  Scheduler.runAsync(() => {
    Sql.makeQuery(
      'SELECT data FROM player_data WHERE uuid = ?',
      (stmt) => stmt.setString(1, uuid),
      (rs) => {
        const data = (rs !== null && rs.next()) ? JSON.parse(rs.getString('data')) : null;
        // Hop back to the main thread before touching game state
        Scheduler.runSync(() => callback(data));
      }
    );
  });
}
// features/stats/join-greeting.js
import { loadPlayerData } from '@/utils/player-data';

registerListener($.PlayerJoinEvent, (event) => {
  const player = event.getPlayer();

  loadPlayerData(player.getUniqueId().toString(), (data) => {
    player.sendMessage(data ? `Welcome back! Level ${data.level}` : 'Welcome, new player!');
  });
});

Best practices

Do

  • Use named exports for everything
  • Group related helpers into small, focused modules
  • Use @/ for anything outside the current directory
  • Export singleton instances when you want shared state
  • Import only what you need

Do not

  • Use default exports, they are not supported
  • Create circular dependencies
  • Put everything in one giant utils/common.js
  • Import modules from inside frequently called functions
  • Forget that every edit cascades to all importers