Skip to content

The config ​

opys.config.mjs is a plain JavaScript file. It describes one pack.

The smallest config ​

mjs
import { defineConfig, userDataDir } from '@opys/dev';
import { forge, java } from '@opys/minecraft';

export default defineConfig({
  output: 'game.opys',
  plugins: [
    forge({ version: '1.20.1' }), // the game, with Forge
    java({ version: '17' }), // the Java that runs it
  ],
  manifest: {
    command: '@forge.command',
    args: ['@forge.jvmArgs', '@forge.mainClass', '@forge.gameArgs'],
    workdir: '${game_directory}',
  },
  run: (manifest) => ({
    manifest: {
      vars: {
        ...manifest.vars,
        root: userDataDir('my-pack'),
        username: 'Player',
        uuid: '00000000-0000-0000-0000-000000000001',
        token: '0',
      },
    },
  }),
});

Four parts, always the same:

PartAnswers
outputWhere does opys build write the bundle?
pluginsWhat is the pack made of?
manifestHow is the game started?
runWhat does only the player's machine know?

defineConfig changes nothing. It is there so your editor can check the config as you type.

The rest of this page goes through each feature: what it does, when you need it, and why it works that way.

Plugins ​

js
plugins: [
  forge({ version: '1.20.1' }),
  java({ version: '17' }),
],

A plugin adds part of the pack: files, variables, pieces of the command line. The list is flat, and each plugin takes one options object.

Why plugins? A real installation is thousands of files. Nobody should write that list by hand, and each plugin knows one part of it well.

When two plugins disagree, the later one in the list wins. For variables the build also prints a warning, because that is usually an accident.

Which plugins exist, and what each one adds: Plugins.

The launch line ​

js
manifest: {
  command: '@forge.command',
  args: ['@forge.jvmArgs', '-Xmx4G', '@forge.mainClass', '@forge.gameArgs'],
  workdir: '${game_directory}',
},

A string starting with @ is a reference: '@forge.jvmArgs' becomes the JVM arguments the forge plugin worked out. Everything else is passed as written.

When you touch it: to add your own flags (-Xmx4G), or to put another plugin's arguments in a specific place, such as an auth agent that must come first.

Why you write it yourself: argument order matters to Java, and only you know what your pack needs. So opys does not guess. It gives you the pieces and you place them.

Typos are caught. A reference that names nothing stops the build and lists what exists. Your editor flags it even earlier.

Two small rules
  • For an argument that really starts with @, write '\\@args.txt'.
  • Take command from the loader ('@forge.command'), not from Java directly. Your config then keeps working if a loader ever needs to start differently.

Variables ​

js
workdir: '${game_directory}',

${name} is a placeholder. It is filled in on the player's machine, when the game is installed and started.

Why: the bundle is the same file for every player, but each player installs somewhere else. So paths are written relative to ${root}, and the player's machine says what root is.

Watch out: a name nobody defines is left as written, silently. If the game shows a player called ${username}, that is what happened.

All the names: Variables.

run ​

js
run: (manifest) => ({
  manifest: {
    vars: {
      ...manifest.vars,
      root: userDataDir('my-pack'),
      username: 'Player',
      uuid: '00000000-0000-0000-0000-000000000001',
      token: '0',
    },
  },
}),

run is called on the launching machine, every time the game starts. It returns two things, both optional:

FieldWhat it is
manifestThe parts of the manifest to replace.
featuresFeatures to switch on, the same as --feature on the command.
js
run: (manifest) => ({
  manifest: { vars: { ...manifest.vars, root: '/srv/minecraft' } },
  features: ['eula'],
}),

When you need it: always, for opys launch. It supplies the install folder and the player.

Why it is separate from manifest: everything under manifest is baked into the bundle and shared with every player. Your home folder and your token must not be. run is never baked in.

The classic mistake

userDataDir() in manifest.vars bakes your home folder into the bundle. The pack then works for you and nobody else. Keep it in run.

Keep ...manifest.vars. Each field under manifest replaces the old one whole, so without the spread the plugins' variables are lost.

A bundle does not contain run. Whoever launches a bundle passes the same values with --var, or from their launcher.

Your own variables and environment ​

js
manifest: {
  vars: { pack_version: '1.4.0' },
  envs: { PACK_VERSION: '${pack_version}' },
},

vars adds variables. envs sets environment variables for the game.

When: a constant you want in several places, or a value a mod reads from the environment.

Also for overrides. manifest.vars wins over any plugin, with no warning. It is the place to settle a variable two plugins both set.

Constants only. These are baked into the bundle, so no paths and no secrets.

Options ​

What a player may change before launching: memory, a resolution, a switch.

js
import { defineConfig, options } from '@opys/dev';

export default defineConfig({
  // ...
  options: options()
    .slider('xmx', {
      title: 'RAM',
      unit: 'MB',
      min: 1024, max: 16384, step: 512, default: 4096,
    })
    .select('preset', {
      title: 'Graphics',
      choices: { low: 'Low', high: 'High' },
      default: 'high',
    })
    .feature('custom_java', {
      title: 'Custom Java',
      options: options().directory('java_home', { title: 'Java folder' }),
    }),
});

Each kind adds an option: the name it sets, then its fields in one object.

KindSetsToNeedsMay have
.slider(name, fields)a variableA number in a range.min, max, step, defaultunit
.select(name, fields)a variableOne of choices.choices, as { value: label }default
.text(name, fields)a variableA line of text.placeholder, default
.file(name, fields)a variableThe path of a file.
.directory(name, fields)a variableThe path of a directory.
.feature(name, fields)a featureOn or off.default, options

Every kind also takes title, which it needs, and subtitle.

  • The name is the variable or feature your manifest already uses: ${xmx} in an argument, allow.features.custom_java in a rule.
  • A title is required. Leaving it out is a type error, and the bundle refuses a head without one.
  • A select starts on its first choice unless default says otherwise.
  • A feature is off unless default: true.
  • The options under a feature only matter while it is on.
  • opys build refuses a name used twice and a slider default outside its range.

WARNING

Choices are shown in the order written, except values that look like whole numbers. JavaScript lists those first, in numeric order: { b: 'B', 2: 'Two', 1: 'One' } is shown as One, Two, B.

One by one. Each kind is also a function, for a list:

js
import { directory, feature, slider } from '@opys/dev';

options: [
  slider('xmx', { title: 'RAM', unit: 'MB', min: 1024, max: 16384, step: 512, default: 4096 }),
  feature('custom_java', {
    title: 'Custom Java',
    options: [directory('java_home', { title: 'Java folder' })],
  }),
],

This is a description, not a value. Options go into the bundle so a launcher can draw a settings screen. The launcher passes what the player chose as vars and features. opys launch does not read them: use --var and --feature.

Translations ​

Any text a player reads can be given per language: title, subtitle, unit, placeholder and a choice's label. Name the language the config is written in with locale:

js
export default defineConfig({
  locale: 'en',
  options: options()
    .slider('xmx', {
      title: { en: 'RAM', uk: "Пам'ять" },
      unit: 'MB',
      min: 1024, max: 16384, step: 512, default: 4096,
    })
    .select('preset', {
      title: { en: 'Graphics', uk: 'Графіка' },
      choices: { low: { en: 'Low', uk: 'Низька' }, high: 'High' },
    }),
});
  • A plain string is in locale.
  • A translation must include locale. opys build refuses one that does not, and any translation in a config with no locale.
  • A language is a tag: en, uk, pt-BR.
  • Names, choice values and defaults are never translated: the manifest reads them.
  • The launcher picks the player's language, and falls back to locale.

cleanup ​

js
manifest: {
  cleanup: [{ includes: ['${game_directory}/mods/*.jar'] }],
},

Deletes files after an install.

When: you remove a mod from the pack. Without cleanup, players who already have it keep the jar forever, because opys only adds and updates.

Why it is safe: a file your pack installs is never deleted, whatever the rule says. So the rule above means "every jar in mods that is not part of this pack".

js
cleanup: [
  { includes: ['${game_directory}/config/**'], excludes: ['**/options.txt'] },
],

excludes spares files. * stays inside one folder, ** crosses folders.

Keep rules narrow

A rule that matches a player's saves deletes their saves. opys refuses the obviously dangerous ones (a relative path, a .., an empty variable), but it cannot know what matters to a player.

Adjusting a plugin ​

js
modrinth({ versions, to })
  .exclude('**/*-sources.jar')
  .addRule('**/sodium-*.jar', 'disallow.os.osx'),

Every plugin can be adjusted after the fact.

When: a plugin is almost right. One file you do not want, one mod that breaks on macOS, one download you want from your own mirror.

MethodDoes
.exclude(match)Drops the matching files.
.addRule(match, rule)Limits them to a platform.
.updateFirst(match, patch)Changes fields of the first match.
.updateMany(match, patch)Changes fields of every match.
.removeIntegrity(match)Installs them without a hash check.
.overwrite(match, 'never')Leaves them to the player.

match is a glob over file paths, or a function. Start a glob with **/: it is compared with the path before variables are filled in.

.overwrite(match, 'never') is for a file the player changes, such as options.txt: it is installed where it is missing and kept from then on. .removeIntegrity is refused on a file carried in the bundle, which is always checked against its own bytes; 'never' is what keeps it.

js
files({ from: 'client', to: (file) => `${GAME}/${file.rel}` }).overwrite(
  '**/options.txt',
  'never',
);

The same plugin twice ​

js
modrinth({ versions: mods, to: intoMods }),
modrinth({ versions: packs, to: intoResourcePacks }).as('resourcepacks'),

When: one plugin, two destinations.

Why .as(): references go by plugin name, so two plugins cannot share one. .as() gives the second its own.

Dev and release from one config ​

js
export default defineConfig(({ mode }) => {
  const dev = mode === 'dev';
  return {
    output: dev ? 'pack-dev.opys' : 'pack.opys',
    // ...
  };
});
sh
opys build --mode dev

When: a test build with extra mods, less memory, or another folder.

Without --mode, mode is the command's name: build, install or launch. The function may be async.

A single file by hand ​

js
manifest: {
  artifacts: [
    {
      path: '${game_directory}/server-icon.png',
      source: { url: 'https://example.com/icon.png' },
      integrity: { sha256: '…' },
    },
  ],
},

When: rarely. links does the same and finds the hash for you. This is the escape hatch.

Everything together ​

A complete pack: Fabric, two mods from Modrinth, its own config files, a dev variant, and cleanup of dropped mods.

mjs
import { defineConfig, files, userDataDir } from '@opys/dev';
import { fabric, java, modrinth } from '@opys/minecraft';

export default defineConfig(({ mode }) => {
  const dev = mode === 'dev';

  return {
    output: dev ? 'pack-dev.opys' : 'pack.opys',

    plugins: [
      fabric({ version: '1.21.1' }),
      java({ version: '21' }),

      // Mods published on Modrinth, pinned by hash at build time.
      modrinth({
        to: (file) => '${game_directory}/mods/' + file.filename,
        versions: [
          'JjCVwmVA', // Sodium
          'N08Z8wog', // Lithium
        ],
      }).addRule('**/sodium-*.jar', 'disallow.os.osx'),

      // Our own files, carried inside the bundle.
      files({
        from: 'config',
        to: (file) => '${game_directory}/config/' + file.rel,
      }),
    ],

    manifest: {
      command: '@fabric.command',
      args: [
        '@fabric.jvmArgs',
        dev ? '-Xmx2G' : '-Xmx4G',
        '@fabric.mainClass',
        '@fabric.gameArgs',
      ],
      workdir: '${game_directory}',

      vars: { pack_version: '1.4.0' },
      envs: { PACK_VERSION: '${pack_version}' },

      // A mod dropped from the list above is deleted from players' disks.
      cleanup: [{ includes: ['${game_directory}/mods/*.jar'] }],
    },

    // Runs on the player's machine, every launch. Never baked into the bundle.
    run: (manifest) => ({
      manifest: {
        vars: {
          ...manifest.vars,
          root: userDataDir(dev ? 'my-pack-dev' : 'my-pack'),
          username: 'Player',
          uuid: '00000000-0000-0000-0000-000000000001',
          token: '0',
        },
      },
    }),
  };
});