Addon format

On this page

Thematic content is JSON, plus JavaScript where an ability or a gun needs behaviour of its own; start here. A pack is data and art: a suit is a JSON file plus a model and a texture, an ability is a JSON file that picks one of Thematic's ability types -- or, when no type does what you want, an ability component script built from parts -- and items, throwables and guns work the same way. No Java. Each content kind has a section below, in the order most packs need them: Suits, Abilities, Throwables (with Actions), Items, Guns.

  • Start a pack with /thematic pack new <name>: it creates <gameDir>/thematic/packs/<name>/ with one working suit, its collection and one ability, plus an AGENTS.md for your AI agent.
  • Examples of every kind, minimal and full: docs/examples/ (index: docs/examples/README.md).
  • Check a pack with /thematic pack validate; it names the file and field of every problem.

What's new in 1.7.0 for pack authors#

Everything below is data or script; none of it needs Java. Short versions are in your pack's AGENTS.md; the generated field tables are docs/content-types.md and the script API docs/thematic.d.ts.

  • Summoned squads ("base": "squad" on a thematic:owned_summon entity: formation patterns, engage any | owner_targets, kit, stand_in, expend) and formations (data/<ns>/formations/<id>.js, a plan(ctx)): docs/examples/entities/squad.*.json, docs/examples/formations/swarm_arc.js.
  • Command consoles (component.console: pages, windows, buttons, choices, unit cards, draw tools, pick modes, quick cast, tooltips, rebuild bars) and unit programs (data/<ns>/programs/<name>.js, sandboxed scripts players run on their units): docs/examples/abilities/console.minimal.js, docs/programs.md, keys and layout in docs/ui-toolkit.md.
  • Status effects: fog, lift_cut, resisted_by, level_decay, on_hurt, pose (lying, lying_back). The fear recipe: docs/examples/status_effects/fog_grounding.full.json with docs/examples/abilities/dread_aura.full.js and dread_ward.full.js. Summoned fliers obey the same lift cut and grounding as players.
  • Cooldowns, sounds and HUD: cooldowns add and keyed; sound heardBy; the target portrait's effect rows and anchor: "speed_bar".
  • Animation: first_person convert | rest | raw (see "Armor clips in first person").
  • Flight profiles: fast.turnRate, lean.stepTicks, lean.snapDegrees, lean.fastPitchBias, poses.armBack, armOut, armHover, armTwist (heavy, servo-like flyers). The default speedLineRate is 0: a profile that wants speed lines sets effects.speedLineRate above 0. Example: docs/examples/flight_profiles/jetpack_heavy.full.json.
  • Projectiles, pulls, doubles: a projectile's aim: "crosshair"; pull maxRise and brake; thematic:owner_copy options die_with_owner, animate_as_owner, shown_to_owner, ai.touch, ai.strike_clip: docs/examples/entities/owner_copy.*.json.

Installing and shipping a pack#

A pack is a folder, .zip or .jar with the files below at its root. Put it in any of these places:

wherewhat loadsnotes
<gameDir>/mods/everythingFor CurseForge and other launchers, which install here. Ship a .zip, or a .jar with no fabric.mod.json if the site insists on jars. It counts as a pack when it contains Thematic content (data/<ns>/armors, abilities, items, guns, gun_effects, throwables, or collections).
<gameDir>/thematic/packs/everythingFolders or zips; handy while developing a pack.
a Fabric mod jareverythingA mod with a fabric.mod.json, e.g. to also ship Java.
a world's datapacks/only what doesn't register items (abilities, throwable behaviour, effects, suit edits)New suits, items and guns need one of the places above.
  • Renaming a pack to <name>.disabled turns it off in any of the first two places; /thematic pack list shows both.
  • New suits, items and guns need a restart. Everything else reloads with /reload (or /thematic pack reload, which also says what changed; see Loading and reloading).
  • Clients and servers must have the same packs. A client that is missing one is told which when joining.

Ids: the file name, never the folders#

Every Thematic content kind -- abilities, throwables, items, guns, gun effects, HUDs, flight profiles, suits -- takes its id from the file name. Folders are yours, for organising: one per character, one per collection. data/thematic/abilities/daredevil/devil_out.js is the ability devil_out, exactly as data/thematic/abilities/devil_out.json would be, so moving a file into a folder never renames it.

  • Two files with the same name under one kind's folder (in different sub-folders, or a .js beside a .json) claim one id. That is an error that names both files, and neither loads; rename one. ./gradlew validateContent and /thematic pack validate report it.
  • The same path in two packs is not a clash: the higher pack's file replaces the lower one's, as for any datapack file. To replace a built-in file, ship yours at the same path.
  • A suit's folder still means something -- armors/<collection>/ is its collection, and an alt/ folder marks an alt -- but its id is still its file name. Suits keep one older rule: a pack's suit replaces a built-in one of the same name whatever collection folder it sits in (see Changing an existing suit). Within one pack, two suits of one name are still an error to validateContent.
  • Vanilla kinds (loot_tables, tags, recipes, worldgen) keep Minecraft's rule: the whole path is the id.
  • Folder names in this guide are the 1.20.1 ones (plural). The repository's own packs are authored in the 26.3 format (recipe/, loot_table/, ...) and downported at build time, see docs/multiversion.md; a pack for the 1.20.1 build keeps the names here.

Reading Thematic's own content: every jar is a pack#

Thematic's own suits, abilities, items, throwables and guns are the same JSON files a pack ships, inside its jars. A .jar is a zip, so read them straight from <gameDir>/mods/. The quickest way to a working file is to copy the closest built-in one into your pack and change its id. Never edit the jar.

jar in mods/holds
thematic-fabric-<mc>-<version>.jar (the main one)data/thematic/abilities, items, throwables, guns, gun_effects, collections, ability_stats, tuning, projectile_stats, and the assets they use, including the skin-style suit model assets/thematic/geo/armor/skin_style.geo.json
thematic-heroes-<mc>-<version>.jar (a collection pack)the suits: data/thematic/armors/<collection>/, their recipes (data/thematic/recipes/<suit>.json), and their art (assets/thematic/geo/armor, textures/armor, models/item)

The collection packs and the collections each one holds are listed in src/common/resources/packs.json in the source repo. Today one jar ships: heroes, which also carries the starwars pack's collections until that pack ships on its own. Launchers may rename files, so match on thematic-*.

Commands an agent can run (from the game folder; on Windows tar -tf <jar> lists and tar -xf <jar> <path> extracts):

unzip -l mods/thematic-heroes-*.jar 'data/thematic/armors/*'          # every suit, by collection
unzip -l mods/thematic-heroes-*.jar | grep '/alfred.json'               # which collection a suit is in
unzip -p mods/thematic-heroes-*.jar data/thematic/armors/batfamily/alfred.json
unzip -l mods/thematic-heroes-*.jar 'data/thematic/abilities/*'         # the heroes' abilities, by character
unzip -l mods/thematic-fabric-*.jar 'data/thematic/abilities/*'         # the engine's own (starters, resistances, ...)
unzip -p mods/thematic-heroes-*.jar data/thematic/abilities/daredevil/devil_out.js
unzip -p mods/thematic-heroes-*.jar data/thematic/ability_stats/ultron.json    # one hero's tuning
unzip -p mods/thematic-fabric-*.jar data/thematic/ability_stats/engine_shared.json  # the engine's tuning

Or open the jar with any zip tool. If you have the source repo, the engine's files are under src/common/resources/ and each pack's under src/packs/<pack>/resources/ (heroes, starwars). Items and throwables are split the same way.

Suits#

A suit is one JSON file plus art. This is the usual starting point for a pack made from a model and a texture. Examples: docs/examples/suits/ (suit.minimal.json, suit.full.json, alt.minimal.json, alt.full.json, recipe.suit_bench.json).

A suit is ONE item, worn in the chest slot. It covers the whole body. There are no separate helmet, leggings or boots items, and no item texture: the inventory icon renders the 3D suit.

Files (id = file name, e.g. night_warden)#

filewhat
data/thematic/armors/<collection>/<id>.jsonthe definition. The folder name is the collection.
data/thematic/collections/<collection>.jsonthe collection card: {"id", "name"} required, plus importance, description, icon, author, version (see below)
assets/thematic/geo/armor/<id>.geo.jsonthe Blockbench GeckoLib model, on the 64x64 player-skin UV layout. Optional: see the skin-style default.
assets/thematic/textures/armor/<id>.pngthe texture. <id>_shiny.png is an optional shiny variant.
assets/thematic/models/item/<id>.jsonalways {"parent": "thematic:item/thematic_armor"}
assets/thematic/lang/en_us.json"item.thematic.<id>": "Display Name", the collection's "thematic.armor_collection.<collection>": "Name", and the names of any new abilities
data/thematic/recipes/<id>.jsonoptional suit bench recipe (below)

Suits register at startup, so a new suit needs a restart. Edits to an existing suit's JSON apply on /reload.

No model? The skin-style default#

A suit with no geo/armor/<id>.geo.json wears Thematic's built-in skin-style model (thematic:geo/armor/skin_style.geo.json). It is flat, like a player skin with armor layers: helmet + hat overlay, chest + jacket and sleeves, thinner leggings, boots over the legs.

Its texture is one 128x64 PNG at textures/armor/<id>.png. The left half (x 0-63) is the usual 64x64 player-skin layout, for helmet, chest, arms and boots. The right half (x 64-127) is the same layout again, but only its body and leg areas are used: they are the leggings.

If you have separate layer_1 / layer_2 armor textures, put them side by side, layer_1 on the left and layer_2 on the right, and that is the texture. The pack template's example_suit.png is a placeholder in this layout.

Fields (all optional)#

fieldtypedefaultmeaning
wipbooltruework in progress: a WIP tooltip, and the suit bench doesn't offer it. Set false for a finished suit. An alt inherits its parent's.
tierint1power tier, 1 to 10
abilitieslist of {"identifier", "keybind", "options"?, "assets"?, "remove"?}[]the kit. Ability ids are built-in (see Reading Thematic's own content) or your own (see Abilities). options / assets as in an ability file; remove (alts) drops the parent's entry.
statslist of {"identifier", "minimum", "maximum"}[]thematic:base_generic_attack, thematic:base_generic_speed, thematic:base_defense, thematic:base_utility, each about 0 to 100
fighting_typelist of strings[]BRAWLER, DUELIST, UTILITY, MAGIC
tagslist of strings[]each name gives the suit what that suit tag file gives (kryptonian, lantern, spidey, tank_i ...), or, if it is another suit's id (spiderman), that suit's whole kit. A name with no file is a plain flag read by code (auto_reload).
flight_typestringNO_HANDSpose while flying: ONE_HAND_LEFT, ONE_HAND_RIGHT, BOTH_HANDS, NO_HANDS, JETPACK, WINGED. Each of the six is a shipped flight profile, so this is shorthand for "flight_profile": "thematic:<the value, lowercased>"
flight_profileidnonea flight profile: this character's own hover speed, fast flight, lean, clips and effects. Wins over flight_type.
jet_groups"arms" | "legs" | "both"none (no filter)keeps only the flight profile's jets on those limbs: arms = the arm and palm anchors, legs = the leg and foot anchors, both = no filter. Head and body jets are dropped under arms and legs. Left out, every jet fires.
suit-recipeidnonethe suit bench recipe, thematic:<recipe file name>
attributeslist of {"identifier", "effect"}[]size and body scales (Pehkui), multipliers: pehkui:base 1.05 is 5% bigger; also pehkui:reach, jump_height, step_height, motion, knockback, health
parentidnonemakes this an alt (skin variant) of another suit, e.g. "thematic:night_warden". See Alts.
gunslist of {"id", "Count"}[]guns the suit comes with
cape, gliderobjectnone{"partCount": 16, "height": 1.3, "width": 0.45, "thickness": 0.001, "texture": "thematic:textures/cape/<id>_cape.png", "shinyTexture"?, "simulation": {"type": "cape"}} ("glider" for a glider). texture is optional: left out, it is <ns>:textures/cape/<suit>_cape.png (<ns>:textures/glider/<suit>_glider.png for a glider simulation), after the suit's id. "remove": true (default false) means no cape or glider: an alt drops its parent's
craftingobjectnoneforce_rarity (0 starter .. 7 unique), force_shiny (bool), chat_message (announced when crafted), craft_sound (id), unique_ownership (bool, unique rarity only)
boss, templateboolfalseinternal: a boss's suit; a template other suits inherit from without being an item. Leave them out.
soundsobjectnonethe suit's own sounds, layered over vanilla's (players only): land_big (a fall of more than 7 blocks), land_vehicle (the same onto a boat, minecart or vehicle), hurt, death, splash, and step: { "<block tag>": [...] } (the first tag the block is in wins; one entry per step, feet interleaved). Each is a sound id or a list played in turn. Alts and suits that tag this suit inherit it. Applied on /reload.

Keybinds: key.thematic.ability_1 to ability_5, key.thematic.ultimate, key.thematic.utility, key.thematic.passive, key.thematic.equip_item, and key.thematic.ability_0 (which is reload while a gun is held).

A suit's own weapons are the built-in equip move with the suit's kit in its options: the same items, bones (hidden while the items are out) and offhand an equip_item file takes, plus label, the lang key it is listed under (its name on the HUD, the suit tooltip and the guide page it links to):

{"identifier": "equip", "keybind": "key.thematic.equip_item",
 "options": {"fields": {"label": "my_pack.equip_staff", "items": [{"item": "my_pack:staff", "count": 1}], "bones": ["StaffHolster"]}}}

An alt that lists equip replaces its parent's kit; {"identifier": "equip", "remove": true} takes it away.

Ladder suits#

"type": "thematic:ladder" is a suit that grows: one item whose level picks a rung. Its abilities list every move the ladder can ever grant. levels gives each rung (1 to 10, each once): stats (preset ramp; favoured one of attack, regen, utility, defense; pace "normal", "swift" or "heavy"), grants and choices (ids from the suit's own abilities; the player picks one choice), cost ([{ "id", "Count" }] at the bench) and trial. legacy_ids keeps old per-level item ids (<id>t<level>) loading as this suit at that level. The rungs are read at startup; /reload re-applies the kit only. Thematic's starters are these files: data/thematic/armors/starters/.

Minimal suit#

{"wip": false, "tier": 2, "abilities": [{"identifier": "punch", "keybind": "key.thematic.ability_1"}]}

Full: docs/examples/suits/suit.full.json. The Wilfred test pack uses test/wilfred.json.

Collections#

data/thematic/collections/<collection>.json, named like the armors/<collection>/ folder:

{"id": "my_heroes", "name": "My Heroes", "importance": 50}

id and name are required: without them the file is skipped. importance orders collections (lower first; built-ins use 0 to 99, unset is 99). brand groups the collection's suits on the suit bench (DC, Marvel...; unset is Other). The name players see is the lang key thematic.armor_collection.<collection>. Examples: docs/examples/collections/.

Alts#

An alt is a skin variant of a suit, its own item: data/thematic/armors/<collection>/alt/<alt id>.json with "parent". It inherits every field it doesn't set:

  • abilities merge with the parent's: an entry with the same identifier replaces it, "remove": true drops it, others are added;
  • stats replace per identifier; tags and attributes are added; other fields override;
  • "cape": {"remove": true} (or "glider") drops the parent's cape or glider.

Ship its own texture (and .geo.json if the model differs), item model and lang name, like a suit. Alt recipes are named alt_<alt id> by convention. Examples: docs/examples/suits/alt.minimal.json, alt.full.json.

Recipes#

Suit bench recipes are datapack files, data/thematic/recipes/<suit id>.json:

{ "type": "thematic:suit_bench",
  "id": "thematic:night_warden",
  "ingredients": [ { "id": "thematic:black_fabric", "Count": 15 }, { "id": "minecraft:ender_pearl", "Count": 2 } ],
  "result": { "id": "thematic:night_warden", "Count": 1 } }
  • At most 10 kinds of ingredient. id and result.id are the suit's item id; the suit's "suit-recipe" names the recipe.
  • To change a built-in suit's recipe, ship a file at the same path (data/thematic/recipes/alfred.json).

Changing an existing suit#

Ship a suit file with the SAME id in your pack: data/thematic/armors/<collection>/<name>.json. It replaces the built-in file completely (packs sit above the mod). Use the built-in file's own collection folder (find it with the unzip -l line in Reading Thematic's own content; invincible is in invincible_universe: data/thematic/armors/invincible_universe/invincible.json). If the folder differs, the pack's file still wins on /reload (logged as "Suit 'invincible': ... replaces ..."), but the item's collection comes from whichever folder is scanned first at startup. Removing the pack restores the original on /reload.

{ "wip": false,
  "tier": 9,
  "suit-recipe": "thematic:invincible",
  "flight_type": "NO_HANDS",
  "abilities": [
    { "identifier": "flight", "keybind": "key.thematic.passive" },
    { "identifier": "ground_pound", "keybind": "key.thematic.ability_1" } ],
  "stats": [ { "identifier": "thematic:base_generic_attack", "minimum": 75, "maximum": 90 } ] }

It is a FULL replace: copy suit-recipe, flight_type, cape and anything else you want to keep from the original. Don't add "parent" (that makes an alt); abilities don't merge with the original's.

Common mistakes#

  • Nothing renders. The geo, texture and item-model file names must equal the suit id exactly.
  • Stretched or garbled texture. The PNG size must match texture_width and texture_height in the .geo.json file.
  • The suit is missing. Adding a suit needs a restart, not /reload. The file must also sit in a collection folder under armors/.
  • "This armor is a work in progress" and no suit bench. wip defaults to true; set "wip": false.
  • The suit loads as an alt. A suit is an alt when its JSON has "parent" or it sits in a folder named alt/ under armors/. Move it out of alt/ if it is meant to stand alone.
  • The collection shows its raw key. Add "thematic.armor_collection.<collection>" to the lang file.

Abilities#

An ability is a JSON file. Its behaviour comes from a type (Java code shipped with Thematic); the file picks the type and fills in its fields. The file name is the ability id. Don't write Java. When no type fits, the behaviour is written out instead, as an ability component: a script (<id>.js) or the file it builds (<id>.json).

  • File: data/<namespace>/abilities/<any folders>/<id>.json, or <id>.js. Folders only organise (Thematic's own Daredevil is in abilities/daredevil/).
  • Id: the file name, <id>, for a file under data/thematic/; <namespace>:<id> for a file under any other data/<namespace>/. Use thematic unless you have a reason not to, and give a new id your pack's prefix (my_pack_frost_breath), so it can't collide with a built-in.
    • A suit names a thematic ability by its bare id ("frost_breath"; "thematic:frost_breath" means the same), and any other ability with its namespace ("my_pack:frost_breath").
    • Lang keys use the same id: "my_pack:frost_breath" (HUD) and "ability.thematic.my_pack:frost_breath" (guide, menus).
    • Tuning rows are found by the id's name alone: my_pack:frost_breath reads the entry frost_breath (written with or without my_pack:). A namespace does not keep two ids apart in the stats files, which is one more reason for the prefix. See Tuning.
    • testpacks/namespaced/ in the source repo is a worked example.
  • Every type, its fields and defaults: docs/ability-types.md (Markdown) and docs/ability-types.json (machine-readable). Grep it for ## \thematic:<type>``.
  • Examples: docs/examples/abilities/ (ability.minimal.json, ability.params.json, ability.full.json, override.json). Every built-in ability is also a file in the main jar (data/thematic/abilities/<id>.json, see Reading Thematic's own content); copying one is the quickest start.

File shape#

fieldtypedefaultmeaning
typestringrequiredthe ability type, e.g. thematic:butlers_pride; a bare name means thematic:
type fieldswhatever the type lists in docs/ability-types.md
_commentstringnonefree text for humans and agents; ignored
optionsobjectnone{"fields": {...}}, the same options a suit entry takes ("name": a lang key or text shown in the suit tooltip instead of the id; "level", "duration" ... per type); re-applied on /reload
assetsobjectnonetexture (the HUD icon) / model / anim / sound / particle / beam (startColor, endColor, size, glowSize ...) overrides; re-applied on /reload

Almost every type takes trigger: press (once per key press), hold (while held), toggle (on/off) or passive (always on). It defaults to what the built-in abilities of that type use, so leave it out unless you want something different.

Make a new ability from an existing one#

The usual case: a hero gets a move that already exists, under their own name. Find the built-in ability, look up its type, and make a new file with a new id.

Minimal (data/thematic/abilities/method_acting.json):

{"type": "thematic:dry_humor"}

Full (data/thematic/abilities/stage_presence.json):

{
  "type": "thematic:butlers_pride",
  "_comment": "Butler's Pride's behaviour under Wilfred's own name, lang key and ability_stats row.",
  "trigger": "press"
}

With parameters (data/thematic/abilities/wilfred_equip.json):

{
  "type": "thematic:alfred_equip",
  "items": [
    {"item": "thematic:wilfreds_prop_shotgun", "count": 1},
    {"item": "thematic:dinnerplate", "count": 1}
  ]
}

Then:

  1. Put the id on the suit: {"identifier": "stage_presence", "keybind": "key.thematic.ability_1"} in the suit's abilities.
  2. Name it in assets/thematic/lang/en_us.json. Add both keys: "stage_presence": "Stage Presence" (HUD) and "ability.thematic.stage_presence": "Stage Presence" (guide, menus).
  3. Tune it (optional): cooldown, duration, damage, range and amplifier come from an entry in a JSON file under data/<namespace>/ability_stats/. The format is in Tuning.

With options and assets: docs/examples/abilities/ability.full.json. The Wilfred test pack (testpacks/wilfred/data/thematic/abilities/) is a complete worked example: eight renamed Alfred abilities and an equip ability handing out the pack's own gun.

Fields worth knowing#

  • items (equip and give types): a list of {"item": "ns:item", "count": 1, "enchantments": {"minecraft:sharpness": 3}}, or the short string "ns:item;count;ns:enchantment:level".
  • Registry references (item, entity, effect, vehicle) are vanilla-style: a bare name means minecraft:. Always write the namespace.
  • corps (Lantern Corps types): green, yellow, red, blue, indigo, violet, orange, black, white.
  • effects (potion types): [{"effect": "minecraft:poison", "duration": 240, "amplifier": 0}]. duration is in ticks (20 per second) and amplifier 0 is level I.

Tuning: the ability_stats JSON#

An ability's cooldown, duration, damage, radius and amplifier are not written in its file: they are one entry in a JSON file, so they can be retuned without touching the behaviour. Every .json in any data/<namespace>/ability_stats/ is read. Give yours a name of its own (my_pack.json): a file with the same path as a shipped one (shared.json, ultron.json) replaces it outright.

{
  "my_pack_frost_breath": { "cooldown": 12, "duration": 3, "range": 6, "damage": 8 },
  "my_pack:ember_dash": { "cooldown": 8, "duration": 3, "range": 6, "damage": 6, "amplifier": 0 },
  "my_pack_ward": { "cooldown": 20, "tuning": { "ward_strength": 0.5 } }
}
fieldmeaning
the keythe ability's id. Only the part after : is used, so thematic:x, my_pack:x and x are one entry
cooldownseconds
durationseconds: how long a phase with lasts("duration") or a zone with no for lasts, and the default for effect durations
rangeblocks. A script reads this as tuned("radius"); it is the default radius of targets("around"), a cone's length, a beam's reach ...
damagehealth points (2 = one heart), the default for damage() with no number
amplifierthe default effect level (0 = I)
disabledtrue turns the ability off for everyone (left out is false)
tuningevery other gameplay number, { "<key>": <number> }; see Any key
  • A field left out (or -1) means "not set": the ability's own default applies. Decimals are fine (2.5); a value that is not a number is logged as a warning and treated as not set. An entry holding only tuning sets no stats.
  • Files are by character: ability_stats/<character>.json for the moves in abilities/<character>/, shared.json for abilities/shared/. The names are only for people; the loader reads them all into one table, so an id must be in one file.
  • An id with no entry gets a default for how its key works: a press 20 s cooldown, 6 s duration, 8 radius, 6 damage (an ultimate 150 s, 20 s, 32, 30); a hold 11 s, 11 s, 20, 1; a toggle 3 s, 8 s, 1, 0; a passive 0 s, 0 s, 0, 1. These are placeholders, not tuning: give every new ability an entry.
  • In a script, tuned("damage"), tuned("radius"), tuned("duration") and tuned("amplifier") read the entry, and tuned("radius", 0.5) is half of it. tuned("range") is an error: the field is read as radius.
  • /thematic pack reload re-reads every file.
  • Numbers for things that are not abilities (guns, items, entities, prefabs ...) sit in data/<namespace>/tuning/<name>.json as { "<subject>": { "<key>": <number> } }. Projectiles (arrows, gadgets) are data/<namespace>/projectile_stats/<group>.json, { "<id>": { "type": "basic", "damage": 1, "cooldown": 3, "radius": 1, "duration": 0, "amplifier": 0, "disabled": false } }.

Any key: tuning keys#

The five stats are only what most actions read. Every other gameplay number is a key of its own, in the entry's tuning block (or a tuning/<name>.json file for a subject that is not an ability):

{
  "run_wild": {
    "cooldown": 20,
    "tuning": { "push_strength": 1.45, "push_lift": 0.5, "absorption_duration": 8, "heal": 5, "box_x": 1.2 }
  }
}
  • The rule: name a key, never write the number. A damage, heal, shield, push, range, box, duration, level, chance, speed, count or threshold in a script or a content file is tuned("push_strength") ({"tuned": "push_strength"} in JSON) with its default as a row. Sounds, particles, offsets, visuals and the beats an animation clip is lined up on may stay numbers. python3 tools/tuning/convert_literals.py (abilities) and convert_content.py (guns, criminals, items, throwables ...) rewrite a script for you; gradlew checkTuningLiterals (part of check) fails on a gameplay number left in a file.
  • tuned(key, times?, otherwise?): any short snake_case key (a-z, 0-9, _), scaled by times; otherwise is used when no entry has the key. The names are shared across abilities (push_strength, slowness_duration, box_x, heal) so a sheet reads the same for every move; a second use in one ability is push_strength_2. In expr("..."), @key reads a key.
  • The value comes from, in order: a command's override, the sheet, the stats file, then (for a key with no row) the suit's option of that name and otherwise. The owner is the ability's id; a key shared by every user of a prefab or a preset lives under the owner all, and an ability's own row beats it.
  • Heal, overshield and to levels are in the action bar's units: 1000 is a full vanilla bar (50 per hit point, ThematicHelper.healthFromDisplay). Damage is a plain hit in hit points. A heal or overshield left without an amount reads the key heal / overshield, else one hit point. There is no units option; a pack that still writes one is converted for a release (and warned about).
  • Commands (op): /thematic ability set <id> <key> <value> takes any key and keeps decimals; get <id> <key>, list <id> (every key, with where its value comes from) and reset <id> [key].
  • The sheet: with Google Sheets on, a worksheet named by googleSheetsTuningWorksheetName (default Tuning) holds the same rows as ability_id | key | value; /thematic pull reads it and the ability dump writes it.
  • An ability is read again in place when one of its keys changes (a command, the sheet, a reload, the join sync), so a change plays on the next press. Guns, criminals, items and the other kinds read their keys as they load: a change reaches them on the next reload.
  • AbilityTuning.CHANGED fires on every set, reset, reload and join sync, for anything that shows the numbers.

Loading and reloading#

  • Files in the mod jar and in <gameDir>/thematic/packs/ load at startup. A world datapack's abilities load on /reload or world start. In multiplayer the server sends every definition to clients when they join.
  • On a reload a new id is created, and an ability component whose file changed takes the change live: players wearing it get the new behaviour on their next tick, a move in progress keeps its target and heading, and someone in a phase the new file no longer has leaves it then. That holds even when the file's shape changes (a press into a hold, a first phase or mob). A change to a Java-backed type (thematic:equip_item, thematic:resistance ...) needs a restart; options and assets always reload. The reload says which ids need a restart.
  • Deleting an ability file removes it: suits stop listing it and anyone using it is stopped. A file that is only broken (a syntax error mid-edit) keeps its last good version running, and the error is reported until you fix it.

Commands for pack authors (operators):

commandwhat it does
/thematic pack reloadReloads every datapack (as /reload, and picks up packs added to thematic/packs), then prints reloaded 212: 1 new, 3 changed, 1 needs restart, 0 errors.
/thematic pack reload <ability id>Re-reads only that ability's file. Fast; the whole reload happens anyway if an ability was added or removed, since suits must be rebuilt.
/thematic pack reload <pack>A full reload, reporting only that pack's abilities.
/thematic pack errors [pack]Every standing problem, as file:line: message >> the offending line. Click one to copy file:line.
/thematic pack watch on [pack] / offWatches thematic/packs/<pack> (unzipped packs only) and reloads what you save: an ability or other content file on its own, anything else (a suit, tags, the tuning stats file) with a full reload. Saves are batched until the files have been quiet for 0.4 s.

New problems go to operators' chat once, when a reload first finds them; /thematic pack errors lists them all.

In a development checkout, src/packs/*/resources and src/common/resources are served to the server as the top datapacks, so a saved source file is what the next reload reads, without running Gradle's processResources. The watcher covers them too.

  • The older override files ("identifier" plus options / assets, no type) still work in the same folder. They change an existing ability.

Checking your work#

  • /thematic pack validate lists every file that failed to load, with the file and the field: e.g. data/thematic/abilities/foo.json: Missing field "items" (list: ...).
  • The log also warns about fields the type doesn't take, with a suggestion: unknown field "iems" for thematic:equip_item; did you mean "items"?.
  • At startup Thematic checks that every built-in ability id still registered (_expected_ids.txt). A missing one is logged as an error.

Common mistakes#

  • Wrong folder: it must be data/<ns>/abilities/, not assets/.
  • Adding .json to the id in a suit: the suit names stage_presence, not stage_presence.json.
  • Leaving the namespace off an ability that is not in thematic: a file at data/my_pack/abilities/frost_breath.js is my_pack:frost_breath, and a suit naming frost_breath finds nothing. validateContent says did you mean 'my_pack:frost_breath'?.
  • Picking an id that already exists (punch): the built-in wins and your file only applies its options/assets. Use a new id.
  • Expecting a changed items list to apply on /reload: restart.
  • Unknown type: look the type up in docs/ability-types.md. Type names come from the Java class (EquipItemAbility becomes thematic:equip_item). A handful are suffixed _ability (thematic:flight_ability, thematic:heat_vision_ability).

Ability components — writing the behaviour yourself#

When no type does what you want, write the ability as a component: data/<namespace>/abilities/<any folders>/<id>.js (a script that builds it) or <id>.json (the same thing written out). The file name is the id. This is how every scripted ability Thematic ships is written (data/thematic/abilities/ in the heroes pack jar), and it is the way to write a new one.

How the pieces fit:

  • A suit is a template. Its abilities list names the components a wearer gets. Putting the suit on attaches them to the wearer; taking it off takes them away. Nothing about the suit file changes.
  • The wearer is the entity. A player or a mob wearing the suit carries one copy of each of its components, with its own cooldown, its own phase, its own gauge -- kept in the same ability data the HUD and the renderers read.
  • A component is built from parts. A key (press, hold or toggle; none for a passive), a cooldown, a gauge, requires, visual, mob, on (reactions), and the property parts (ultimate, stun_immune ...).
  • Phases are sets of parts the wearer is in for a while. A key's use leads, via then, to cooldown, ready, or a phase. Entering a phase attaches its parts (lasts, every, on, until, on_exit ...); leaving takes them off.
  • Systems run the parts, one per part, in Java, the same for every ability. A new ability almost never needs new Java; when it does, it is a new part every ability can then use (docs/engine-seams.md).
component(component => {
    component.cooldown();
    component.press(press => {
        press.selfEffect("minecraft:slowness", { duration: 0.3, amplifier: 100 });
        press.animation("kick_drop");

        press.wait(0.25);

        press.sound("minecraft:entity.player.attack.crit");
        press.targets("around", target => {
            target.damage();
        });
    });
});

The body runs once, when the pack loads, and builds the component -- the same JSON a hand-written file holds, checked by the same rules, synced and reloaded the same way. It never runs on the key press and never sees a player, so press.wait(0.25) does not block: it moves a cursor, and everything written after it happens that much later in the move. There is no loop to write and no player to look at; the game does the waiting and the looking.

Each callback is handed a builder for one part. Name the parameter for what it is -- component, press, phase, every, reaction, target -- never a single letter:

you writeyou are handedit stands for
component(component => ...)componentthe ability: its parts, and its phases
component.press(press => ...), component.hold(hold => ...), component.toggle(on => ..., off => ...)the keywhat using the key does first, and (then) where it leads
component.phase("name", phase => ...)phaseits own running order on arrival, phase.lasts, phase.every, phase.on, phase.until, phase.onExit, phase.then ...
phase.every(1, every => ...), phase.onExit(exit => ...)a list of actionswhat happens each time
phase.on("hurt", { melee: true }, reaction => ...)reactionwhat answering does -- a running order of its own, so reaction.wait(0.25) works -- and reaction.then(...) where it leads
press.targets("around", target => ...), press.when(guard, branch => ..., branch => ...)a list of actionswhat happens to each target, or on each branch
component.use("thematic:armed", options, hit => ...)whatever the prefab handsa prefab

Every builder that runs actions can write every action: animation, sound, particles, damage, effect, selfEffect, heal, targets, when, push, teleport, projectile, beam, ... and action(type, fields) for any other action type. Each takes what it is about first, then an options object: press.sound("devil_out", { pitch: 1.2 }). Options are camelCase (showParticles); the file spells them as the JSON does (show_particles).

  • Leave numbers out. A number you do not write is this id's tuned value from ability_stats. A size that follows a tuned stat is tuned("radius", 1.5); what an answered hit was worth is fromEvent("amount"); coloured dust is dust([r, g, b], scale). A gameplay number you do need is a key, never a literal: push("target", { strength: tuned("push_strength") }) with a row my_ability,push_strength,1.45 ("Any key" above).
  • The cooldown is a part. Write component.cooldown() ({ from: "press" } to start it on the press -- only for a press that leads into a phase, whose cooldown would otherwise wait for the phase to end; { steady: true }; { persistent: true } for one that outlives the suit). Its length is still the stats file's. A hold with a gauge has no cooldown part -- the gauge is its limit -- so its ways out lead to "ready".
  • One file, one ability -- or, in plain loops, many, each named: component("my_pack_frost", component => ...).
  • Plain code is fine. A constant, a helper function or a loop at load time is ordinary JavaScript. Only the builders reach the game. Superman's speed_punches.js walks the ticks of its one-second flurry and writes a step for each.
  • Rhino, not Node. Scripts run in the same sandbox every Thematic script uses: no Java, no endless loops, errors that name the file and line. ES5 plus arrow functions, let/const, destructuring, template strings, shorthand properties and for...of; no default parameters, spread, classes or computed keys. A const declared inside a loop keeps the value of the loop's first pass: use let there.
  • A Java-backed type is still one line: ability("my_pack_beam", "thematic:power_i_beam", { corps: "green" }). Families of them are written in loops (corp/beam.js).
  • A line to ride is a key plus a tether part: component.tether({ line: "swing" | "slingshot" | "hang", entity: "thematic:web_line_entity", poses: "web", clips: {...}, sounds: {...} }). The key still says where it leads (a swing's hold opens a phase that answers release); the tether hears the key after it. The lines are a thematic:tether entity with "mode": "swing", and a body on them is posed by a pose set, assets/<ns>/poses/<set>.json. Examples: docs/examples/abilities/tether.minimal.js, tether.full.js.
  • Summon options: summon also takes attributes ([{ attribute, amount, operation: add | multiply_base | multiply_total }], persistent on the summoned mob), scales (Pehkui target scales), display: true (a harmless picture of the thing: no AI, never hurt, a TNT whose fuse never runs, discarded if it comes back from a save), lasts (seconds; now also for a plain mob or TNT) and options (handed to the body as written; worked-out numbers are worked out at the cast). thematic_explosion takes at: "here" | "spot" | "summoned" and shockwave_damage. Examples: docs/examples/abilities/summon_options.minimal.js, summon_options.full.js, vfx_duration.minimal.js (vfx(id, { duration }) in seconds).
  • Bolts and patterns: a bolt's pierce: { unless: guard, max } flies through bodies the guard does not hold for; return (the thrown return component) and on_return bring it home. pattern takes yaw_offsets and shape: "spiral" (turns, radius, height, jitter); an orbit's step spreads a group by index. Examples: bolt_options.*.js, pattern_shapes.*.js.
  • A belt or quiver is a loadout part: component.loadout({ inventory, size?, display?, roster? }), with press.loadoutCycle(inventory) on its key and press.loadoutUse(inventory, { autoSwap }) on a use move (autoSwap names the wheel switch that turns swapping on). A wheel switch may start as a game setting instead of on: wheelSwitch("auto_swap", { on: true, onSetting: "auto_swap_utility" }). Examples: loadout.minimal.js, loadout.full.js.
  • Moving the wearer and others: movement also takes charge: { toward: "locked" | "marked", key, face } (straight at the lock-on or at the body press.markTarget(key?) stored), lunge: { ticks, distance | speed, along: "yaw" | "look", keep_vertical }, swim_boost and tunnel: { floor, blocked_by } (walk through blocks over a floor); double_jump: { per_level, level, min_level } counts air jumps from a level (none below min_level). spin({ who, degrees, ticks }) turns a body round; push takes on_collide (actions run on the first body it flies into) and collide_radius; teleport places at to: "impact" | "behind_target" | "nudge" | "clear_spot" | "block_top" (each with its room checks and an else); look_at({ spot }), extinguish({ who }), set_active({ clear_capture }); mark_spot({ along: "block_top" }). A fast flight's wind-up runs windup.stages: [{ at: tick | "launch", every, do }], and a glide takes a when guard. Examples: targeted_dash.*.js, lunge_and_swim.*.js, teleport_placements.*.js, tunnel.*.js, windup_stages.*.js, body_verbs.*.js.
  • An aimed meter (fill a bar by holding the crosshair on one body, then act on it): the heroes pack's thematic:aimed_meter prefab (corp/death_stare.js).
  • Target guards in a reaction to hit_landed, attack or kill (marked_by, target: {...}) ask about the body the hit struck, not the wearer's locked target; so do a standing damage_bonus's and a loot's when.
  • Worked examples: docs/examples/abilities/brimstone_parry.js (a hold that answers a hit), the heroes pack's daredevil/devil_out.js (a press that opens a window that pulses), superman/thunder_clap.js (a move a mob can use too), shared/heat_vision.js (a hold on a heat gauge, with a beam every tick and a mob's channel), wolverine/claw_stab.js (a prefab window answering the next hit), and the two in docs/pack-template/data/thematic/abilities/example_suit/.
  • Still name it in lang and put it on a suit by id, exactly as for a JSON ability.

Editor autocompletion. docs/thematic.d.ts declares the whole script API for TypeScript-aware editors, generated from the same table the game installs the API from: every method with its documentation, typed options, every shipped prefab's use(...), and the closed sets (events, visuals, tuned stats, target selections ...) as string-literal unions. The pack template ships a jsconfig.json that points at it; /thematic pack export-types sets up any pack (see docs/editor-setup.md). docs/script-api.json section ability_script is the same thing as data; section ability_format lists every part, phase part, event, guard and action.

The component file#

A script builds this; you can also write it by hand. Every part has a builder method with the same meaning, so this section is the reference for both.

{
  "type": "thematic:composed",
  "parts": {
    "cooldown": {},
    "press": {
      "steps": [
        {"at": 0,    "do": [{"type": "thematic:animation", "animation": "ground_pound"}]},
        {"at": 0.3,  "do": [{"type": "thematic:area_damage"}, {"type": "thematic:area_launch", "strength": 0.8, "lift": 0.4}]}
      ]
    }
  }
}
  • parts holds the component's parts by name; phases holds the named phases, each a map of phase parts. An unknown part is an error naming where it belongs ("slows_cooldowns belongs on a phase").
  • The key. press, hold or toggle, at most one; none makes a passive. Each has steps, the running order: at is seconds after the key, 0 (or no at) on the key itself; steps may be written in any order and are sorted, and two at the same moment keep the order you wrote them in. then is where the key leads once its steps have started: cooldown (the default), ready (no cooldown), or a phase. A hold must name the phase it holds open; a toggle may name the phase it holds while on, and has off_steps, on_equip and bones of its own.
  • do is a list of actions -- the same ones throwables use (Actions), plus the ones an ability needs:
typefields (default)does
thematic:self_effecteffect (required), duration, amplifier, ambient (false), show_particles (true), show_icon (true)a status effect on the wearer
thematic:animationanimation (required), speed, on (self / target)plays a clip on the wearer (a suit's options.animation still wins), or on a targeted player
thematic:area_launchstrength (1), lift (0), radiusthrows everyone in radius the way the wearer is facing
thematic:lungestrength (1), lift (0)throws the wearer the way they are facing
thematic:targetsselect (around / cone / look / remembered / attacker / victim ...), radius, length, width, height, range, pick (all / nearest / first), at (here / spot), remember, required, dopicks who the move lands on and runs do once aimed at each
thematic:ifif (a guard), do, elseruns do when the guard holds, else when it doesn't
thematic:pushwho, direction, strength (1), lift (0), flat, mode (add / set), keep, falloff, min_strengthsets or adds velocity
thematic:teleportto, gap, distance, spreadmoves the wearer onto the target or behind it
thematic:look_atwho (self / target)turns the wearer to face the target
thematic:remove_effecteffecttakes an effect off the wearer; with none, the one an effect_added event brought
thematic:mark_timekey (required)stores the world time on the wearer, for a client visual to count from
thematic:mark_spotkey (required)fixes a point for the rest of the use, for at: "spot" and away_from_spot
thematic:extinguishradius, height (3)puts out fire in the box around the wearer
thematic:swap_held_itemcycle, hand, doswaps the held item for the next in the cycle

The actions most moves use, by what you want. A script writes each as a method on any builder (press.lunge(...), target.ignite(...)); a JSON file as {"type": "thematic:<snake_case name>", ...}. The list above and Actions are a part of it; DOCS/thematic.d.ts (interface Actions) has every action with its options.

you want tomethods
move the wearerlunge (a throw the way they face), drive (steer every tick), push (set or add velocity, anyone), jump, teleport, carry
pick who it lands ontargets("around" | "cone" | "look" | "remembered" | "attacker" | "victim", options?, target => ...), burst (everyone around a point)
hurt or change themdamage (the tuned damage; { element: "fire" } for a damage type), ignite (set on fire; { seconds }), effect, knockback, heal, explode, areaDamage
leave something on the groundzone (a patch that keeps hitting whoever stands in it, for the tuned duration), splash (a coloured pool), decal
fire somethingprojectile (a bolt or a throwable, { throwable: "<ns>:<id>" }), beam, spray
show and sound itparticles, particleCircle, particleDome, sound, animation
remember something for latermarkSpot (a point, for { at: "spot" }), store, setFlag, scale (a stat while a phase lasts)
time itwait (in a running order), repeat, and a phase's every

A dash that scorches a cone and leaves a burning patch where it ends:

component(component => {
    component.cooldown();
    component.press(press => {
        press.lunge({ strength: 2.2, lift: 0.15 });
        press.wait(0.3);
        press.targets("cone", { once: "cast" }, target => {
            target.damage({ element: "fire" });
            target.ignite({ seconds: 4 });
        });
        press.markSpot("landing", { anchor: "feet" });
        press.zone({ at: "spot", floor: true, radius: tuned("radius", 0.5), every: 0.5 }, standing => {
            standing.damage({ damage: tuned("damage", 0.25), element: "fire" });
            standing.ignite({ seconds: 2 });
        });
    });
});

Aiming at someone: thematic:targets. Inside its do, actions are about the target: thematic:damage hurts it, thematic:effect lands on it, sounds and particles happen where it stands. "pick": "nearest" keeps only the closest. "required": true calls the whole use off -- the rest of the step, the later steps, and the cooldown -- when nobody is found. "at": "spot" centres an around search on the point a thematic:mark_spot fixed earlier. A push with "direction": "heading" goes where the wearer faced as the use began.

Leave the numbers out. An action with no damage, radius, duration or amplifier takes the ability's tuned value from the stats file when it runs; writing a number pins it forever. There is no cooldown length anywhere in the file for the same reason. A size that should follow a tuned stat is {"tuned": "radius", "times": 1.5}; {"event": "amount"} is what the event being answered was worth. A number the stats do not cover is a key of its own: {"tuned": "push_strength"} with a row in the stats file.

A marker ability -- one that exists so some other part of the mod can ask whether the wearer has it -- is {"type": "thematic:composed", "parts": {"cooldown": {}}}: no key, nothing to do. light_footed, ace_in_the_hole, looting, mending and friends are written that way.

Examples: docs/examples/abilities/composed.minimal.json and composed.kick_drop.json. Everything in the file is checked before it runs, and by ./gradlew validateContent offline -- the same check, run from the same code.

Phases — powers that last, tick and react#

A key can lead into a phase: something the ability is doing for a while. This is what Devil Out is -- a blast on the press, then five and a half seconds of pulses -- and what a parry stance is: raised while the key is down, answering the next hit.

{
  "type": "thematic:composed",
  "parts": {"cooldown": {}, "press": {"steps": [{"at": 0, "do": [{"type": "thematic:animation", "animation": "devil_out"}]}], "then": "unleashed"}},
  "phases": {
    "unleashed": {
      "lasts": "duration",
      "every": [{"seconds": 1, "do": [{"type": "thematic:targets", "select": "around", "do": [{"type": "thematic:damage"}]}]}]
    }
  }
}
phase partmeaning
lastshow long it lasts: "duration" (the tuned duration), seconds, or {"time": ..., "steady": false, "then": exit}. then is where running out leads: cooldown (default), ready, another phase, or {"to": "cooldown", "fraction": 0.25}. Left out, it lasts until a reaction, an until or a release moves on
stepsa running order that starts on arrival, the same shape as the key's
every[{"seconds": 1, "chance": 1, "clock": "age", "do": [...]}]: what happens while here. Clocks: age (the wearer's; default), phase (ticks since entering, 1 on the first), world, animation (the phase's clip, with frames/cycle)
on{"<event>": {"if": guard, "do": [...], "steps": [...], "then": exit}}: how the phase answers the world. steps still runs after then has left the phase
until[{"if": guard, "then": exit, "do": [...], "check": "after"}]: ways out checked every tick
hold_open{"while": guard, "end_when": guard, "max": seconds, "grace": seconds}: a timed phase can't run out while while holds
on_exitactions run as the phase is left, whichever way -- also when the wearer dies, logs out or takes the suit off
blocks_movement, hud_suffix, animation_speed, stun_immune, unstoppable, immune, damage_bonus, damage_taken, slows_cooldowns, holds_cooldown, hides_swing, phase, body, camera, lock_input, hide_boneswhat the wearer is while here

The order of a tick is fixed: the phase's work (until before, every, collide, until after), then its time. So a window of N ticks does its work N + 1 times, the last on the tick it closes -- as the Java windows always did.

While in a phase the ability counts as active: the HUD shows the time left, an ultimate can switch it off (its on_exit still runs), and a client visual that checks whether the ability is active sees it.

Events (in a phase's on, or the component's own on, live for as long as the wearer has the ability):

eventwhenguards it can ask
hurtthe wearer is about to be hurt, before armourmelee
damagedthe wearer was hurtmelee, source, element ...
effect_addeda status effect was just put on the wearereffect, effect_group
attackthe wearer lands a melee hit; select: "victim" aims at it-
collidethe wearer runs into something solid (phases only)wall_or_ceiling, touching_for, in_state_for
releasethe key of a hold came up-
hit_landed, swinga hit the wearer dealt; a swing of the armsource, dealt, hand
tickevery tick, on a passive ({"every": 0.2, "ready": true})-
pressthe key pressed again while a press's phase runs: the phase takes the key-

Guards ("if" on a reaction or thematic:if, and requires on the component, which blocks it -- the HUD padlock -- while it fails) are listed in docs/script-api.json → ability_format.guards. Every key written must hold; any is the way to say "or".

A hold names the phase it holds open, and that phase says what letting go does: phase.on("release", reaction => reaction.then("ready")). Worked example, a parry stance with a riposte: docs/examples/abilities/brimstone_parry.js.

Visuals. Shaders, overlays and world-space effects are code, so a file names one from a shelf: "visual": "thematic:daredevil_vision" (or a list). The shelf, with what each looks like: docs/script-api.json → ability_format.visuals. Some visuals are drawn as a layer on the suit's own model (thematic:eye_glow); a component draws at most one of those.

Wheel switches. "switches" puts switches for the ability on the ability wheel; whatever reads one asks whether it is on for the wearer. Superman's Solar Glare is nothing else:

component(component => {
    component.cooldown();
    component.visual("thematic:eye_glow");                                        // lit while the switch is on
    component.wheelSwitch("solar_glare", { icon: "minecraft:magma_cream" });     // fields: name (the id), icon, on (false)
});

Reloading a component. A changed file takes effect live on /thematic pack reload, whatever changed -- even its shape: a press may become a hold or a passive, and wearers carry on under the new file from their next tick. Someone in a phase the new file dropped leaves it then. Only a change between a component and a Java-backed type needs a restart.

Prefabs — parts bundled for reuse#

A prefab is a bundle of parts with a name: a window that answers the next hit, a stance held while a toggle is on, an item held for as long as a phase runs. component.use(id, options, body) applies one:

component(component => {
    component.cooldown();
    component.use("thematic:claws_out");                            // Wolverine's claws requirement
    component.press(press => press.sound({ cycle: "wolverine_combat", key: "combat" }));
    component.use("thematic:armed", { window: 5 }, hit => {         // press → a 5 s window; the first hit answers
        hit.cancelEvent();
        hit.animation("claw_stab");
    });
});

Prefabs live in libraries, data/<namespace>/ability_prefabs/**.js, each declaring any number:

prefab("my_pack:ward", {
    on: "component",                                    // what it is applied to: component or phase
    doc: "Turns away one element for as long as the wearer has the ability.",
    options: { element: { type: "string", doc: "the element", required: true } },
}, (component, options) => {
    component.on("hurt", { element: options.element }, reaction => reaction.cancelEvent());
});
  • Every library is read once per load, lowest pack first, so a higher pack's prefab with the same id replaces a lower one. A library only declares prefabs; an ability file only applies them.
  • use() checks the options against the prefab's declared ones -- an unknown option, a missing required one, or a plain boolean/number/string given something else is an error at the line that applied it.
  • What a prefab writes is written into the file as if you had written it; the file keeps the ids it used in "prefabs", for provenance only.
  • The engine's library (data/thematic/ability_prefabs/core.js): thematic:armed (a window the first hit answers), thematic:while_on (a toggle's phase), thematic:slow_motion (slowed time for a phase), thematic:equip (an item held for a phase), thematic:buff (stat scales for a phase), thematic:aura (a pulse around the wearer). Each is typed in docs/thematic.d.ts.
  • A change to a library reloads every ability (they may all use it).

Mobs: a move a boss can use too#

Bosses and criminals wearing a suit fire its abilities through their own controller. A component offers itself to them with a mob part: the controller fires the key's steps once, with the mob as the wearer, and writes no cooldown -- nothing would count it down on a mob -- pacing the move from these numbers instead. For a mob, a push along "heading" goes straight at the target it was aimed at. Guard a part only a player should do with {"player": true}. Thunder Clap:

component(component => {
    component.cooldown();
    component.mob({ maxRange: 7, cooldown: 4, weight: 12 });   // also minRange; cooldown is seconds
    component.press(press => {
        press.animation("thunderclap");
        press.selfEffect("minecraft:slowness", { duration: 1, amplifier: 6 });
        press.wait(0.75);
        press.when({ player: true }, branch => { branch.extinguish(); });
        press.targets("around", target => {
            target.damage();
            target.push("target", { direction: "heading", flat: true, strength: 8 });
        });
    });
});

A mob never runs a player's phases or gauge. A move whose work is in its phases gives the mob a channel of its own: component.mob({ lasts: 8, active: true, needsTarget: true }, mob => { mob.every(0.6, every => every.beam()); }) -- its every rules count on the channel's own ticks, a collide reaction with a then ends it early, as does until, and end runs however it ends. shared/heat_vision.js is the worked example.

Ability types by theme#

A quick way to find a type to build a suit ability from, grouped by the power it gives.

powerability type(s) (a built-in id)
Equipmentequip_item, give_item_permanently; a utility belt is inventory_select + inventory_use (utility_belt, utility_use)
Flight and glidingflight_ability (flight), propelled_flight, gliding
Leapingleaping_ability (leaping)
Super speedsuper_speed_ability (speed_mirage), enhanced_reflexes (speed_force)
Healinghealing_factor_ability, regenerative_healing_factor
Water breathing, fire immunitywater_breathing_ability, fire_immunity_ability (immunity_fire)
Damage resistance and immunityresistance (per damage type, level one to three), fall_immunity, explosion_immunity, magic_immunity, poison_immunity
Effect immunitypotion_immunity (effects listed by the suit), all_status_effect_immunity (immunity_all)
Beamsheat_vision_ability, optic_beam, hand_beam, radiation_beam, water_beam
Transformationtransformation (toggle a form), banner_hulk_transform (swap to another suit)
Bladestoggle_claw, toggle_bone (show and hide model bones)
Invisibility, intangibilityinvisibility_ability, intangibility_ability
Teleportingteleportation, blink, raiden_teleport
Web swingingweb_sling, grapple
Wall crawlingclimbing, toggle_climbing
Size changingsize_change (grow, shrink)
Ground smashground_pound, hulk_smash
Charged punchpunch, flying_punch (powerful_punch)
Shieldswater_shield, magic_shield, power_shield
Projectilesarrow_catching, projectile_immunity, projectile_reflect (arrow_reflect)
Throwablessmokescreen, throwing_knife, grenade
Lightninglightning_throw
Telekinesispsionic_push (push only)

No ability type covers these yet: metal skin, sentry mode, duplication, spellcasting, sonic waves.

Passive and press abilities#

typewhat it does
potion_immunitystrips the listed effects (potionEffects) from the wearer
projectile_immunityprojectile damage is cancelled; beams still hurt
arrow_catchingwith an empty main hand, arrows are caught into the inventory
teleportationteleports to the block you look at, within range (default 32, 4..256)
ability_cooldowna meter that fills while a move runs and switches that move off when it is full
transformationa press toggles a form on and off; a 0..1 transition follows over time ticks

Throwables#

A throwable is one JSON file: how it flies, what it looks like, and lists of actions (see Actions) that run when it hits, lands, burns its fuse or ticks. All throwables are the same entity (thematic:thrown), so a new one needs no Java and no restart. Adding "item" also registers a gadget item (thrown from the gadget inventory with its key); new items do need a restart. Don't write Java.

  • File: data/<namespace>/throwables/<any folders>/<id>.json; the file name is the id. Id: <namespace>:<id>; a bare name in any field means thematic:.
  • Every built-in throwable is a file under data/thematic/throwables/: in the main jar the 15 gadgets gadget_*.json, grenade, tank_grenade, molotov and the thrown weapons and constructs; in the heroes pack jar exploding_teeth, milk_bottle_grenade, flashbang, rubble, powder_gas_bomb and a few more (see Reading Thematic's own content). Copying one is the quickest start.
  • Examples: docs/examples/throwables/throwable.minimal.json, gadget.minimal.json, throwable.full.json (every field, defaults). Real pack example: testpacks/wilfred/data/thematic/throwables/prop_smoke_bomb.json.
  • Reloads with /reload and is synced to clients. Keys starting with _ ("_comment") are ignored. Unknown keys log unknown field 'x' in throwable <id>; did you mean 'y'?.

Units: *_ticks are ticks (20 per second). duration and cooldown are seconds. velocity is blocks per tick, gravity blocks per tick².

Fields#

fieldtypedefaultmeaning
modelidthe throwable's idGeckoLib model: assets/<ns>/geo/entity/<path>.geo.json + textures/entity/<path>.png. Reuse any existing one, e.g. thematic:gadget_batarang, thematic:grenade, thematic:molotov
item_spriteidnonedraw this item (flat, like a snowball) instead of a model
namestringitem name, else entity.<ns>.<id>translation key used in death messages
render.scalenumber0.75model scale
render.face_velocitybooltrueturn the model to face its flight (batarangs); false for round things
render.animatedboolfalseloop misc.idle from animations/entity/<path>.animation.json; that file must exist
sizenumber or [w, h]0.3hitbox, blocks
velocitynumber4.0throw speed when thrown as a gadget
inaccuracynumber1.0spread when thrown as a gadget
gravitynumber0.05per tick; 0 = flies straight
dragnumber0.99speed kept per tick in air
on_blockstick pass stop bouncestick (bounce if bounce > 0)on a block hit that doesn't detonate: embed like an arrow / fall into the block and wait for the fuse / stop dead and hover / reflect
bounce (alias ricochet)number0share of speed kept per bounce
max_bouncesint3then it sticks
fuse_ticksint0 (none)runs on_fuse, then disappears
explode_on_impactboolfalsedetonate on any hit = explode_on_entity_hit + explode_on_block_hit
explode_on_entity_hit / explode_on_block_hitboolexplode_on_impactdetonate on that kind of hit only
arming_ticksint0block hits younger than this don't detonate
hits_ownerboolfalsecan hit whoever threw it
ignore_owner_vehicleboolfalsepasses through the vehicle its thrower drives
collide_with_entitiesbooltruefalse = flies through entities (unless it detonates on them)
discard_on_entity_hitbooltruedisappears after a non-detonating entity hit
impact_originhit self_block target_blockhitwhere impact actions are centred: exact hit point / its own block / the struck entity's block
homingnumber or {strength, range}noneturns toward the nearest hittable target; strength 0-1 per tick, range 16
trail{particle, count, interval, spread}noneparticles left in flight (simple particles only)
land_after_ticksint-1counts as landed after this long even in the air (gravity off)
tick_intervalint1on_tick runs every N ticks
tick_only_when_landedboolfalseon_tick counts from landing, and only then
lifetime_after_landingint-1gone this many ticks after landing
damage radius duration amplifiernumbers3, 3, 6, 2the stats actions read unless they set their own
cooldownnumber3gadget cooldown, seconds (scaled by the wearer's utility stat)
itemtrue or {id, max_count, creative_tab}nonealso register a gadget item (default id = throwable id, max 1). Always in the Gadgets tab; creative_tab adds another
classification{stun, single_use, bleed, damage_type}from actionsbench rules: one stun per loadout, single-use goes on the prepped shelf, damage_type (fire, ice, electric, explosive, sonic, ...) is what weakness/resistance match
on_impactactions[]any hit (after on_entity_hit for a plain hit; before it when detonating)
on_entity_hitactions[]an entity was hit
on_block_hitactions[]a block was hit without detonating
on_fuseactionson_impactthe fuse ran out
on_tickactions[]every tick_interval ticks
useactions[]non-empty = a gadget used in place, never thrown; runs on the user

Thrown weapons. A throwable with a model is also a thrown body: return (mode, after_ticks, max_distance, on_block, noclip, drop_after...), a per-axis bounce ({ vertical, horizontal, min_speed, on_hit }), on_land { zone, lasts, end_move } (leaves a zone body where it comes to rest), on_refused_hit (return, discard or pass on a body its thrower may not hit), recall and catch. Every field is in docs/content-types.md; examples: docs/examples/throwables/thrown_weapon.minimal.json, thrown_weapon.full.json.

Tuning. If data/thematic/projectile_stats/*.json has an entry for the id (bare path for thematic: ids, full id otherwise), its damage/radius/duration/amplifier win wherever the column is above 0, and its cooldown always wins. Otherwise the JSON numbers are used.

Thrown from Java. Abilities spawn one with DataThrowableEntity.create(world, owner, "grenade") and may override that throw's setDamage, setRadius, setEffectDuration (ticks), setExplodeOnImpact, setFuseTime (seconds).

Recipes#

  • Grenade: "fuse_ticks": 50, "on_block": "pass", "collide_with_entities": false, "impact_origin": "self_block", "on_impact": [{"type": "thematic:thematic_explosion"}].
  • Impact bomb: "explode_on_impact": true, "on_impact": [...].
  • Hovering gas cloud: "on_block": "stop", "land_after_ticks": 100, "tick_only_when_landed": true, "tick_interval": 5, "lifetime_after_landing": 85, "on_tick": [{"type": "thematic:status_area", ...}].
  • Batarang: "item": true, "on_entity_hit": [{"type": "thematic:projectile_damage"}].

Common mistakes#

  • duration is seconds; fuse_ticks/arming_ticks/tick_interval are ticks.
  • render.animated: true without an animation file: the model errors when drawn. Leave it false.
  • model names the geo id, not a path: "thematic:gadget_batarang", not "geo/entity/gadget_batarang.geo.json".
  • A new item needs a game restart (items register at startup) and a lang key item.<ns>.<id> plus assets/<ns>/models/item/<id>.json with {"parent": "builtin/entity"}.
  • An action inside a list needs "type". Errors name the list and index: on_impact[1]: ....
  • Effect actions need a thrower; nothing happens for ownerless throwables.
  • on_block: "bounce" with bounce 0 is rejected.

Actions#

An action is one thing that happens: {"type": "thematic:<action>", ...fields}. Throwables list them (on_impact, on_tick, use, ...). Actions run on the server.

Stats. Most actions read damage, radius, duration (seconds) and amplifier from the throwable's stats (top-level fields, tuning rows, per-throw overrides). Writing one of those inside the action pins it for that action only: {"type": "thematic:flash", "radius": 8}.

Ids. A bare id means thematic:, so write vanilla ids in full: "minecraft:blindness".

Generic#

typefields (default)does
thematic:damagedamage (stats), element (none)hurts the entity hit; element (fire, radiation, bleed, ...) is what the hit is made of, so a suit's weakness, resistance or immunity to it answers
thematic:area_damagedamage, radius (stats), element (none)hurts everyone in radius but the thrower and allies
thematic:effecteffect (required), duration, amplifier (stats), ambient (false), show_particles (true), show_icon (true)status effect on the entity hit (inside thematic:targets, the target)
thematic:area_effecteffect, duration, amplifier, radiusstatus effect on everyone in radius but the thrower and allies
thematic:explodepower (2), fire (false), break_blocks (false)vanilla explosion
thematic:igniteseconds (3)sets the entity hit on fire
thematic:knockbackstrength (0.5)pushes the entity hit away
thematic:particlesparticle (required), count (12), spread (0.5, or [x, y, z]), speed (0.05), y_offset (0), anchor (feet / body)burst of particles; particle is an id or {"type": "minecraft:dust", "color": [r, g, b], "scale": 1}
thematic:soundsound (required), volume (1), pitch (1)plays a sound; minecraft:... or a Thematic sound name
thematic:discard-removes the throwable

Effects (shared with trick arrows)#

These run the same code as the matching trick arrows. All read the stats; all need a thrower.

typeextra fields (default)does
thematic:basic_damage-damage to everyone in radius but the thrower (radius exactly 1 = nothing)
thematic:emp-players in radius get every gadget and arrow put on an amplifier-tick cooldown
thematic:flash-flashed (whiteout) for duration in radius
thematic:smoketear_gas (false)thrower invisible 3 s, amplifier smoke clouds for duration in radius; tear gas = nausea + slowness instead
thematic:freeze-frozen (duration, amplifier) in radius, lays ice; counts as a stun, ice damage type
thematic:electric-damage + shock in radius; stun, electric damage type
thematic:bindbind_equipment (true)slowness + weakness (amplifier) for duration in radius, bola on the head; stun
thematic:explosivepower (fixed) or power_per_damage (1 × damage), break_blocks (false)vanilla explosion credited to the thrower; explosive damage type
thematic:incendiary-sets everyone in radius alight (duration × 20 ticks); fire damage type
thematic:statuseffects: [{effect, duration, amplifier}]listed effects on everyone in radius but the thrower
thematic:sonic-lingering sonic field: slowness, nausea, grounded, for duration; sonic damage type
thematic:antidote-cleanses and heals the entity hit by damage

Throwable and gadget#

typefields (default)does
thematic:projectile_damagedamage (stats), element / damage_type (the throwable's)the gadget hit: damage × thrower's utility attack, typed for weaknesses
thematic:impact_damagedamage (stats)arrow-style hit: speed × damage
thematic:lightning_strike-the entity hit is struck by lightning
thematic:thematic_explosiondamage, radius (stats), fire (false), fire_seconds (0), break_blocks (false), shockwave (false), shockwave_damage (0; what the shockwave itself deals, needs shockwave: true), at (here / spot: the point thematic:mark_spot fixed / summoned: where the summoned body stands, inside a summon's do)Thematic's explosion: flat damage to everyone the thrower may hit, dome particles
thematic:status_areaeffects: [{effect, duration, amplifier, ambient (false), show_particles (true), show_icon (true)}], shape (sphere / box / block_box), radius, respect_teams (true), include_owner (false), particle_on_each {system, height, radius, count, particle}effects on everyone in an area
thematic:particle_circlesystem (circle, thick_smoke, unfilled_circle, tornado, whirlpool, helix, ground_slam, sonic_scream), height (1), radius (stats), count (32), particle (required; id or dust), block_center (false), y_offset (0)a ring of particles
thematic:particle_domeshape (full_dome / dome_only / burst_only / atmospheric / column / expanding_ring), radius (stats), height (0), count (60), particle (required; id or dust), y_offset (0), at (here / spot: the point thematic:mark_spot fixed)a dome of particles, or just its burst
thematic:healamount (in the action bar's units: 1000 is a full vanilla bar, 50 one hit point; left out, the ability's tuned heal key, else 50; or {"event": "amount"}), to (heal up to this level, never past it; with amount, by the amount but no further), target (self / hit)heals. The retired units option (health / display / balance) is still read for one release, converted, and warned about
thematic:feedfood (1), saturation (1)hunger for the user
thematic:clear_effects-removes the user's status effects
thematic:run_ability_hookhook (required)runs named Java registered by the mod or an addon (built in: thematic:molotov)

Examples#

Minimal: {"type": "thematic:flash"}.

Full:

{
  "type": "thematic:status_area",
  "shape": "block_box",
  "radius": 30,
  "effects": [ { "effect": "thematic:flashed", "duration": 2.5, "amplifier": 1 } ],
  "particle_on_each": { "system": "circle", "height": 0.25, "radius": 2, "count": 64, "particle": "minecraft:firework" }
}

Common mistakes#

  • "effect": "blindness" means thematic:blindness; write "minecraft:blindness".
  • particle must be a simple particle id or a minecraft:dust object; no block particles.
  • A stat inside an action ("radius": 6) is no longer tunable from projectile_stats; leave it out to stay tunable.
  • thematic:heal and thematic:overshield amounts are in the action bar's units (1000 is a full vanilla bar, ThematicHelper.healthFromDisplay = value / 50 hit points); damage is a plain hit in hit points. There is no units option any more.

Items#

Registered items (weapons, shields, bows, ingredients, food, currency, lantern rings) defined as JSON. No Java.

Layout and ids#

<pack>/data/<namespace>/items/<any/sub/folders>/<id>.json   -> registers item <namespace>:<id>
<pack>/assets/<namespace>/models/item/<id>.json            -> item model (always needed)
<pack>/assets/<namespace>/lang/en_us.json                   -> "item.<namespace>.<id>": "Display Name"
  • Id = file name without .json. Sub-folders only organise; they are NOT part of the id. Ids: lowercase a-z 0-9 _.
  • Two files with the same <namespace>:<id>: the first source wins (mod jars, then thematic/packs/* by name). A pack cannot replace a built-in item.
  • Registered at game start from mod jars and <gameDir>/thematic/packs/<pack>/. A new item, or a changed type or identity field (creative_tab, creative_order, max_count, max_damage, rarity, fireproof, food, and each type field marked identity in docs/content-types.md), needs a restart. /reload re-reads the files and retunes existing items' hooks, stats, looks and traits. World datapacks cannot add items.
  • Every client must have the same pack (item registries must match).
  • Built-in items: data/thematic/items/ in the main jar (weapons/, shields/, bows/, ingredients/, food/, currency/, lantern_rings/; see Reading Thematic's own content). Per-type examples: docs/examples/items/<type>.minimal.json and <type>.full.json. Pack example: testpacks/wilfred/data/thematic/items/stage_cane.json.

Common fields (every type)#

fieldtypedefaultmeaning
typeidrequiredItem type, see below. A bare name means thematic: ("weapon" = "thematic:weapon").
creative_tabidnone (not in any tab)thematic:suits, thematic:alts, thematic:equipment, thematic:ingredients, thematic:blocks, or any other registered tab id (minecraft:combat, minecraft:food_and_drinks...).
creative_orderintlast, then by idPosition inside the tab. Any int; lower comes first. Built-in values: equipment weapons 1000-4990, shields 5000+; in thematic:ingredients, ingredients 1000-2750, food 7000+, fluid containers 8500, currency 9000-9050, wallet 9200, loot boxes 9300 and 9400 (the tab's Java-registered materials sit at 8000 and its Java currencies at 9100).
max_countint 1-6464Stack size.
max_damageint >= 00 (no durability)Durability. Setting it forces a stack of 1; do not also set max_count > 1.
raritystringcommoncommon, uncommon, rare, epic (name colour).
fireproofboolfalseSurvives lava/fire as a dropped item.
foodobjectnoneMakes it edible, see thematic:food.
_comment, _anythingany-Ignored. Use for notes (JSON has no comments).

Any other field logs unknown field "x" for type T; did you mean "y"? and is ignored.

Melee items and item hooks. A thematic:melee item also takes denied_sound (a swing refused while the item cools down plays it), dual_wield (a copy in a free off hand), mining: [{ tag | blocks, speed, suitable }] and can_mine, and recipe_remainder: { item: "self" | id, break_chance }; hooks on_inventory_tick (any slot), on_break (also on status_wearable) and on_incoming_projectile (a cancel_event lets the shot pass through). Any item type's hooks may read a move's tuning with tuning_from: "<ability id>" (the holder's move, else the item's stats); on_use_on_block and on_block_broken may ask block_type in their own if. Verbs: notify({ who, args }), stop_using, throw_held with count: { from_stack, max }, spread and a clip per hand, item_clip({ stop }). A bow's arrow_override_from takes track (keep the last arrow on the wearer). Every field: docs/content-types.md; examples: docs/examples/items/melee.full.json, deflecting_bat.minimal.json, deployed_gadget.*.json, thrown_fan.*.json, status_wearable_break.*.json.

Types#

thematic:weapon / thematic:weapon_equippable#

A GeckoLib-rendered hand weapon (sword, staff, cane, prop). weapon_equippable is a legacy variant that behaves the same; use weapon.

fieldtypedefaultunitsmeaning
modelidthe item's own id-Render with another weapon's geo/texture/animation (e.g. "riddler_cane").
attack_damagenumbernone (no attack attributes)health points added (full health is about 50)Main-hand attack damage bonus. Set together with attack_speed.
attack_speednumbernoneattacks/second modifierMain-hand attack speed modifier (vanilla sword is -2.4). Set together with attack_damage.

Assets (namespace <ns>, path <m> = model or the id): assets/<ns>/geo/item/<m>.geo.json, assets/<ns>/textures/item/<m>.png, optional assets/<ns>/animations/item/<m>.animation.json, plus assets/<ns>/models/item/<id>.json with "parent": "builtin/entity" and display transforms (copy one from a built-in weapon, e.g. models/item/riddler_cane.json).

{ "type": "thematic:weapon", "model": "riddler_cane", "max_count": 1, "rarity": "uncommon",
  "attack_damage": 4.0, "attack_speed": -2.0, "creative_tab": "thematic:equipment" }

thematic:shield / thematic:ww_shield#

A blocking shield (GeckoLib model, same asset layout as weapons, keyed by id). ww_shield also moves itself to the off hand and can be thrown. No extra fields.

thematic:bow#

A bow that fires the wearer's quiver arrows (needs a suit with a quiver ability; otherwise does nothing). Always stacks to 1. No extra fields. Assets as weapons, keyed by id.

thematic:ingredient#

A plain crafting material (flat item). No extra fields. Needs models/item/<id>.json (usually "parent": "item/generated") and a texture.

thematic:modeled#

A plain item rendered with a GeckoLib model (geo/item/<id>.geo.json). No extra fields.

thematic:food#

Edible item (GeckoLib-rendered, assets keyed by id). food is REQUIRED:

food fieldtypedefaultunitsmeaning
hungerint0half-drumsticksHunger restored.
saturationnumber0.0multiplierSaturation modifier (vanilla bread 0.6).
always_edibleboolfalse-Can be eaten when full.
meatboolfalse-Wolves eat it.
snackboolfalse-Eaten fast.
effectslist[]-Each { "effect": id, "duration": ticks (default 1), "amplifier": 0-255 (default 0, 0 = level I), "chance": 0.0-1.0 (default 1.0) }. duration is in TICKS (20 = 1 s).
{ "type": "thematic:food", "food": { "hunger": 4, "saturation": 0.3, "always_edible": true,
  "effects": [ { "effect": "minecraft:regeneration", "duration": 50, "amplifier": 1, "chance": 0.1 } ] } }

thematic:currency#

A banknote the wallet counts. amount (int, REQUIRED): its value.

thematic:lantern_ring#

A corps ring that fires a burst using the wearer's corps power charge. Optional model (as weapons).

Bespoke types (one Java item's behaviour under a new id)#

Take the common fields; the weapon ones also take model. Behaviour is hard-wired to the original item (e.g. thor_hammer throws and returns).

  • weapons: thematic:deadpools_katana, hook_swords, kryptonite_sword, nekron_scythe, ww_sword, ww_lasso, nightcrawler_rapier, nth_mace, weapon_axe, thor_hammer, ww_bn_axe, bo_staff, gambit_bo_staff, explosive_gel_gun, grapple_gun, kinetic_card, joker_card, harley_bat, billy_club, billy_club_staff, billy_club_grapple, billy_club_nunchuck, aquaman_trident
  • other: thematic:garrick_shield, taser, camera, boxing_glove, and helmet-slot ability items lasso, silver_lasso, embrace_fear, bola, arm_wrap, construct_grab, ult_block
  • Many of these are now aliases of a generic type carrying the shipped item's fields (gambit_bo_staff → thrown_weapon, kinetic_card → weapon, taser / camera / boxing_glove → melee, thor_hammer → thrown_weapon): a file naming the old id still loads.
  • Behaviour that isn't in a type needs Java (an addon mod registers a new type with ItemTypes.register from its thematic_addon entrypoint). Don't write Java for a pack.

Errors and validation#

  • Bad definitions are skipped (the game still starts) and logged: Skipping item <id> (<file>): <field>: <problem>.
  • /thematic pack validate (op) lists items that failed at startup and items defined but never registered (added after startup, or in a world datapack).
  • Unknown fields: a log warning with the closest known field.

Common mistakes#

  • Putting the item in data/<ns>/items/weapons/foo.json and expecting id <ns>:weapons/foo: the id is <ns>:foo.
  • Numbers as strings ("amount": "5"): write 5.
  • duration in seconds: it is ticks.
  • max_damage with max_count > 1: Minecraft rejects it.
  • Missing assets/<ns>/models/item/<id>.json: the item renders wrong in hand and GUI even with model set; model only redirects the GeckoLib geo/texture/animation.
  • attack_damage on a bespoke type: warned as unknown and ignored (only weapon/weapon_equippable read it).
  • Expecting /reload to pick up a new item: restart.
  • Missing lang key: the name shows as item.<ns>.<id>.

Guns#

Purpose: define guns and gun visual effects as JavaScript (.js) or JSON, in the same pack layout as suits, abilities, items and throwables. Each file in data/<ns>/guns/ registers one item <ns>:<file name>. A script builds exactly the JSON described below, under the same field names, so every table in this section is the reference for both; see Writing guns, gun effects and HUDs in JavaScript.

Examples: the pack template's data/thematic/guns/example_gun.js, example_base_shotgun.js and huds/example_hud.js; docs/examples/guns/ (gun.minimal.json, gun.full.json, gun_effect.minimal.json, gun_effect.full.json) for the JSON form. Shipped guns: data/thematic/guns/<character>/*.js in the heroes pack jar (thematic-heroes); the engine jar keeps only cold_gun and tommygun, which its own code names by id, and the effects its code names (blood, explosions, muzzle_fire, and the tommygun's bullet_yellow / muzzle_yellow).

Source of truth: api/content/gun/GunContent.java (loading, ids, inheritance, validation), api/content/gun/GunSchema.java (known fields, required fields), api/content/gun/script/GunScriptApi.java (the script API; docs/thematic.d.ts is generated from it), api/registries/item/GunItemJsonParser.java, api/registries/item/HurtingItem.java (Builder.withJsonObject), api/registries/item/MiscItem.java, api/registries/item/trait/*Trait.java (features), api/registries/item/trait/FireModeJson.java, api/registries/item/trait/GunConditions.java, api/vfx/effect/*Effect.java.

Guns are hitscan only: there is no ammo item type, no projectile entity, and no custom explosion. A IGunDamageAbility on the shooter's suit is how special rounds (armor-piercing, incendiary, ...) are expressed instead.

Writing guns, gun effects and HUDs in JavaScript#

A gun, a gun effect or a HUD can be a .js file beside (or instead of) the JSON ones. The script runs once, when the game starts (a HUD: also on /reload), in the same Rhino sandbox as ability scripts, and builds the same JSON this section describes, under the same field names: maxAmmoCapacity in a script is maxAmmoCapacity in the file. So a script gun can parent a JSON gun and the other way round, /thematic pack validate reports a script's problems like a file's, and every table below is the reference for both. The script never runs when the gun fires.

data/thematic/guns/alfred/alfreds_shotgun.js (heroes pack), the gun the mod is tuned against:

// Six in the tube, loaded a shell at a time. The prepare runs half a load: it plays once when the
// reload starts and the load cycles after it, so it reads as the way in rather than as a phase of
// its own.
gun(gun => {
    gun.stats({
        maxAmmoCapacity: 6,
        maxAmmoPerReloadIteration: 1,
        pelletCount: 30,
        pelletSpread: 0.1,
        damage: 1.9,
        rpm: 68,
    });
    gun.recoil({
        gunRecoilInitialAmplitude: 1.3,
        shakeRecoilAmplitude: 2.0,
        shakeRecoilSpeed: 2.0,
        viewRecoilAmplitude: 10.0,
    });
    gun.sounds({ fireSound: "shotgun_fire", reloadSound: "reload" });
    gun.fireModes("SINGLE");
    gun.clip("fire", "animation.model.fire", { duration: "rpm", loop: false });
    gun.feature("MuzzleFlash", feature => feature.effect("FIRING", "muzzle_fire"));
    gun.feature("Aiming", { zoom: 0.25 });
    gun.reloadPhase("PREPARING", 250, "animation.model.prepare");
    gun.reloadPhase("RELOADING", 700, "animation.model.load", phase => {
        phase.sound(300, "shotgun_shell_load", { volume: 1.0 });
    });
    gun.reloadPhase("COMPLETING", 600, "animation.model.finish", phase => {
        phase.shake(0, 180, { initialAmplitude: 0.25 });
    });
});
JSONscript
the filegun(gun => { ... }), or gun("MISC", gun => { ... }). One per file; name (the file name) and type are written for you
parent, abstractgun.parent("thematic:example_base_shotgun"), gun.abstract()
model, texture, modelScale, glowingParts, rotations, ...gun.model("alfreds_shotgun", { texture: "penguins_shotgun" })
hudgun.hud("thematic:gun_default")
the Core tablegun.stats({ ... })
Timing and reload (reloadAnimation, idleAnimations, cooldowns)gun.timing({ ... })
Aiming and HUDgun.aiming({ ... })
Recoil and handlinggun.recoil({ ... })
Soundsgun.sounds({ ... })
fireModesgun.fireModes("SINGLE", "AUTOMATIC")
clipsgun.clip(state, clip, options?); gun.clip(state, null) switches a parent's off; gun.clip(state, clip => { clip.when(condition, clip); clip.otherwise(clip); }) is a list, first match wins
effectsgun.effect("HIT_SCAN_ACQUIRED", "tracer")
featuresgun.feature("Aiming", { zoom: 0.25 }); lists in a callback: feature.effect(...) (MuzzleFlash), feature.fireMode(name, options) (FireMode), feature.part(name, options) (PartVisibility, ActiveMuzzle), feature.fireSound(sound, options) (Sound)
phasedReloadsgun.reloadPhase(phase, duration, animation, { condition, shakeEffects }?, phase => { phase.sound(at, name); phase.shake(at, duration); phase.particle(at, name); })
a gun effectgunEffect("MUZZLE_FLASH", { texture: "...", duration: 80 }) in gun_effects/
a HUDhud(hud => { hud.text("{ammo} / {capacity}", { ... }); hud.bar("reloadProgress", { ... }); hud.texture(id, { ... }); }) in huds/
  • A field in the wrong method is an error that names the right one (stats(): unknown field "fireSound" ...; it is written with gun.sounds({ fireSound: ... })), with the file and line. So is saying one thing twice: two clips for one state, two features of one type, one field set twice.
  • Conditions are the same objects and strings as in JSON (see Conditions): { onEmptyReload: true }, "ammoCount <= 3".
  • Comments are // comments. _comment keys are refused in a script.
  • Rhino, not Node. ES5 plus arrow functions, let/const, destructuring, template strings, shorthand properties and for...of; not default parameters, spread, classes or computed keys. A constant or helper function at the top of the file is fine: it runs once, at load.
  • Editor autocompletion. docs/thematic.d.ts declares all of it, generated from the table the game installs the API from: typing gun. lists the builder, gun.recoil({ offers only recoil fields with their defaults, and every closed set (fire modes, reload phases, fire phases, clip states, event types, feature types, effect types, HUD kinds, anchors, conditions and values) is a string-literal union. The pack template's jsconfig.json points at it. docs/script-api.json section gun_script is the same thing as data.

Layout#

<pack root>/                                  (mod jar root, or <gameDir>/thematic/packs/<folder or .zip>/)
  data/<ns>/
    guns/<any folders>/<id>.js|.json          one item per file: gun | misc ("type")
    gun_effects/<any folders>/<name>.js|.json optional; named visual effects
  assets/<ns>/
    geo/item/<model>.geo.json                 gun 3D model (required for a gun)
    animations/item/<model>.animation.json    gun animations (optional; clips missing -> no motion)
    textures/item/<model>.png                 gun texture (required for a gun)
    models/item/<id>.json                     item model: hand/GUI transforms (required, see below)
    lang/en_us.json                           "item.<ns>.<id>": "Display Name"
  assets/thematic/
    sounds.json, sounds/...                   new sound ids (only assets/thematic/sounds.json is scanned)
  • <id> = the file name without .js / .json. Sub-folders only organise (one per character is the convention); they are not part of the id. x.js beside x.json, or two x files in different folders of one pack, is an error and neither loads.
  • <model> = the item's model field if set, otherwise <id>.
  • There is no pack.json and no assetNamespace: the asset namespace is always the data/<ns>/ namespace of the file.

Ids#

thingidhow other files refer to it
gun/misc in data/thematic/guns/x.js (or .json)thematic:x"x" or "thematic:x"
same in data/mypack/guns/x.jsonmypack:x"mypack:x" (always with the namespace)
effect in data/thematic/gun_effects/y.js (or .json)y"y" or "thematic:y"
effect in data/mypack/gun_effects/y.jsonmypack:y"mypack:y"
  • name is optional. If present it must equal the file name; otherwise it is ignored and /thematic pack validate warns.
  • The file name must be a valid id path: lowercase a-z 0-9 _ - ..
  • The same id defined in two packs: the first source (below) wins, and the log and /thematic pack validate name both files. Built-in ids can't be replaced this way. Two files of one name in different folders of one pack are an error, and neither loads (see Ids).
  • Cross-references: effects[].name.
  • Sounds are referenced by bare sounds.json key ("tommygun_fire"), or "ns:key".
  • Display name lang key: item.<ns>.<id>.

Where files load from (in this order)#

  1. Every loaded mod jar that has data/<ns>/guns or data/<ns>/gun_effects (Thematic's own and pack jars such as thematic-heroes).
  2. Dev environment only: src/common/resources/data/.
  3. <gameDir>/thematic/packs/<entry>/data/ for each folder or .zip entry, sorted by name. Entries ending .disabled are skipped.
  4. Pack archives in <gameDir>/mods/ that are not mods (a .zip, or a .jar with no fabric.mod.json), where launchers install packs.
  • All effects load before any gun, so a gun can use an effect from any pack.
  • Assets in a thematic/packs entry are served as a resource pack (client) automatically; no pack.mcmeta needed.
  • Client and server must have identical packs. Items are registry entries, and pack fingerprints are compared at login (PackFingerprint).
  • World datapacks can't add guns (items register once at startup). /thematic pack validate flags such files as "defined but not loaded".

Restart required#

  • Items are registered at startup, before the registries freeze, and a gun script runs then, once. Adding, removing or editing a gun or effect file (either format) needs a full game restart. /reload only re-runs validation.
  • Asset-only edits (texture/geo/animation) are ordinary resource-pack assets; resource reload (F3+T) should pick them up (unverified).

Moving from the old thematic/gunpacks layout#

thematic/gunpacks/ is no longer read. If one is found, the log prints one note listing each file's move, and /thematic pack validate shows a warning.

oldnew
thematic/gunpacks/<p>/items/x.jsondata/<ns>/guns/x.json (<ns> = the old pack.json assetNamespace, usually thematic)
thematic/gunpacks/<p>/effects/y.jsondata/<ns>/gun_effects/y.json
thematic/gunpacks/<p>/pack.jsondelete it

Ids stay the same when <ns> is thematic and the file name equals the old name, so existing worlds keep their items.

Assets a gun needs, and model reuse#

Asset root for a gun = <ns>:<model or id>. If model contains :, it is used as-is ("othermod:foo").

assetpathrequired
geoassets/<ns>/geo/item/<model>.geo.jsonyes; rendering throws "Unable to find model" without it
animationsassets/<ns>/animations/item/<model>.animation.jsonno; named clips that are missing just don't play
textureassets/<ns>/textures/item/<model>.pngyes
item modelassets/<ns>/models/item/<id>.jsonyes. Keyed by item id, so always <id>, never <model>. Must be "parent": "builtin/entity" with a display block. Hand/GUI placement comes from its display transforms. Copy an existing gun's item model and change textures.particle.
langassets/<ns>/lang/en_us.json → item.<ns>.<id>recommended
soundskeys in assets/thematic/sounds.jsonfor new sounds
  • "model": "alfreds_shotgun" makes a new item that draws with alfreds_shotgun's geo and animation file. The new item still needs its own models/item/<id>.json and lang entry. Its stats, reload timings and features all come from its own JSON. Animation names you reference (phasedReloads[].animation etc.) must exist in the reused animation file. Example: testpacks/wilfred/data/thematic/guns/wilfreds_prop_shotgun.json.
  • "texture": "penguins_shotgun" gives that borrowed model a skin of your own, which is how one model serves several characters. Left out, the texture follows model; with no model either, both follow the file name.
  • In another namespace, reuse a Thematic gun with "model": "thematic:alfreds_shotgun". texture takes a namespace the same way.
  • There is no separate animation override key: animations follow model.
  • Built-in animation names the engine plays: animation.model.idle, animation.model.fire. Optional, played only if present: animation.model.off_ground, standing, walking, crouching, walking_aiming, walking_backwards, walking_left, walking_right.
  • Reload sounds come from sound keyframes in the animation file, not from JSON.
  • Keyframe times: keep them on 0.05 s boundaries.
  • Use a pose that exists in no file (e.g. animation.model.static_pose) to keep a timing with no motion. See data/thematic/guns/cold_gun.js in the engine jar, which ships no animation file.

JSON tolerance and validation#

  • Unknown top-level keys (and unknown keys inside phasedReloads[] and FireMode fireModes[]) don't stop loading, but /thematic pack validate and the log warn: unknown field "dammage" (ignored); did you mean "damage"?.
  • Keys starting with _ are comments ("_comment": "...") and never warned about.
  • Enum strings are upper-cased before lookup (case-insensitive), except phasedReloads[].phase, which must be exact upper case.
  • The feature type is case-insensitive ("MuzzleFlash" = "MUZZLEFLASH").
  • Item type: GUN (default when omitted) | MISC, case-insensitive.
  • ./gradlew validateContent (build-time, in the source repo) also runs every gun, gun-effect and HUD script, JSON-parses every gun file and the examples, and checks type, maxAmmoCapacity, effect texture, clip names and reload beats.

Gun item ("type": "GUN" or omitted)#

Units: all *Duration, *Time, *CooldownDuration and duration values are milliseconds unless noted. rpm = rounds per minute. Distances are in blocks. Damage is raw damage points (2 = one heart) per hit.

Core#

fieldtypedefaultunitsmeaning
typestringGUN-item kind
namestring= file name-optional; must equal the file name (the id always comes from the file name)
modelstring= id-reuse another gun's geo+animation+texture (see above)
parentidnone-inherit another gun's whole definition and override only the fields you set, e.g. "thematic:alfreds_shotgun". Objects merge key by key; a list replaces the parent's outright. The parent must be a gun in some loaded pack
abstractboolfalse-a shape to inherit, never registered as an item. See below
clipsobjectnone-which states this gun animates and the clip for each. See below
hudidnone-the HUD that draws this gun's counters, e.g. "thematic:example_hud"
modelScalenumber1.0factorclamped to 0.1–1.0
maxAmmoCapacityint or "infinite"REQUIREDroundsmagazine size. "infinite" = never reloads. Missing → the file fails validation and isn't loaded
chamberCapacityint0roundsextra rounds on top of the mag ("+1")
maxAmmoPerReloadIterationintunlimitedroundsrounds loaded per reload cycle. 1 = shell-by-shell
damagenumber5.0HP pointsper hit. Headshot ×3.5. Falls to 0 at 2× maxShootingDistance
maxShootingDistancenumber200blocksrange. Hip fire capped at 100. Capped at 50 when pelletCount > 1. Also capped by view distance
rpmint600rounds/minfire rate
burstShotsint3shotsshots per BURST pull
fireModesstring[]none-SINGLE / AUTOMATIC / BURST. Give at least one; with none the gun gets no fire modes and can't fire (validation warns)
pelletCountint0pellets>1 = shotgun
pelletSpreadnumber1.0cone (unverified unit)added to inaccuracy when pellets > 0
inaccuracynumber0.03cone (unverified unit)hip-fire spread
inaccuracyAimingnumber0.0coneaimed spread

Reserve ammo is infinite: reloading always tops up the magazine and never checks the player's inventory, so there is no ammo item to give a player or list here.

abstract. true makes a definition a shape rather than a gun: resolvable as a parent, never registered as an item. parent already let one gun be written as another plus its differences, but every gun file is also an item, so there was nowhere to put "a pump shotgun" or "a service pistol" — the timings, the clip names, the reload shape, the recoil curve — with no model, no name and no item. An abstract base is exempt from the required fields (maxAmmoCapacity, fireModes) for the same reason an inheriting gun is: they are the concrete gun's job. The flag does not inherit — a gun that extends a base is a gun — so a base extending another base says so itself.

clips. An object of state to clip, saying which states this gun animates and how: idle, fire, prepareFire, completeFire, changeFireMode, reload, walk. Leave it out and the engine guesses as it always has, hunting for animation.model.idle, preparefire and completefire on every gun and holding the pose when a model has none. That is right for a pump shotgun that wants none of them, but it makes a missing clip and a deliberately absent one look identical, and while you debug a model the log says Animation not found once a frame for a clip you never meant to write. Declare them and the gun is taken at its word: a state you did not list gets no controller at all, and a state listed as null is one you switch off that a parent declared.

Three spellings, cheapest first. A bare string is just the clip name. An object says more about it. A list is several of those, tried in order, first match wins — which is how idle clips already behaved and is now open to any state.

"clips": {
  "fire": "animation.model.fire",
  "idle": { "clip": "animation.model.idle", "duration": "clip", "loop": true, "blendIn": 2 },
  "completeFire": null
}
fieldmeaning
clipthe animation name (required in the object form)
durationmilliseconds; "clip" for the clip's own length, read from the model's animation file, plus its blendIn; or "rpm" for the gap between shots. Omit to keep the state's own cooldown field
looptrue to loop, false to hold the last frame. Omit to keep the state's own: idle loops, every other state holds
blendInwhole ticks to blend from the pose on screen to the clip's first frame. Default 0, a hard cut, which is what every state did before this key
conditionwhen this entry applies, for a state that lists several

Which entry plays. The first whose condition holds, checked each time the state starts; its clip, duration, loop and blendIn all come from that one entry, so a state never plays one entry's clip for another's length. If none holds, the state animates nothing until one does. "random" is an error here: it draws from a pool that only idleAnimations and fireAnimations set up, and in clips it would never pass.

Blending. The blend comes before the clip, not over its start, so a clip with blendIn: 3 finishes three ticks (150 ms) later than it would with a hard cut. "duration": "clip" already adds this; an explicit millisecond duration does not, so include the blend in it yourself. A shot usually wants 0: the kick is the first frame, and blending into it softens it.

What reads duration. idle, prepareFire, completeFire, changeFireMode, and reload on a gun with one reload clip. Not fire: a shot always lasts the gap between shots, from rpm, so any other duration there is a warning. A phased reload times each step from its own phasedReloads entry.

clips and the older lists. A state named in clips plays what clips picks, so fireAnimations and the names in idleAnimations are not played for it (a warning says so). idleAnimations still does the other thing it always did, switching the idle fidget on and timing it. An idle clip on a gun with no idleAnimations never plays, and that is a warning too.

walk is accepted but not read yet: the walk cycle plays animation.model.walking whatever clips says.

reload is for a gun with one reload clip. A gun with phasedReloads names a clip per phase, so there is no single name for reload to give and declaring one is an error. The same goes for reloadAnimation, which becomes a one-step phased reload when the gun is built, so a gun with both would only ever play reloadAnimation.

hud. The id of a HUD (see below), e.g. "thematic:example_hud". Most guns name none and get the built-in overlays.

Beats and clips end together. A phase's duration, its clip's animation_length and its clip's last keyframe should all be the same number. When the clip stops moving before the beat ends, the gun stands still for the difference — which reads in game as a stall and is invisible in any log, because every number involved is individually correct. validateContent checks reload phases against their clips and fails on a mismatch.

Engines. There is one. Until 2026-09-20 a gun could name legacy or cycle in an engine field; the old state machine has been removed and the key is retired, so a file that still carries it fails validation rather than being quietly given the only engine there is. A gun's client state runs on api/weapon/cycle: a reload is a schedule decided the moment it starts, from the count the player can see - a preparing beat, one loading beat per round owed, a completing beat - and nothing in it waits on a packet. Firing abandons a shell-fed reload and keeps the shells already in; putting the weapon away is honoured from any state.

Timing and reload#

fieldtypedefaultunitsmeaning
reloadCooldownTimeint1000msreload length when using reloadAnimation
reloadAnimationstringnoneanim namesingle-clip reload. Used only when phasedReloads is empty
phasedReloadsobject[]none-multi-stage reload, see below. Replaces reloadAnimation
reloadShakeEffectsobject[]none-camera shake during a reloadAnimation reload: {start, duration, ...} like shakeEffects below
idleAnimationsobject[]none-[{name?="animation.model.idle", duration REQUIRED ms, condition?}]
fireAnimationsobject[]none-[{name?="animation.model.fire", condition?}] (no duration)
prepareFireCooldownDurationint0mswind-up before the first shot
completeFireCooldownDurationint0mswind-down after firing stops
enableFireModeCooldownDurationint0msdelay when switching fire mode
prepareIdleCooldownDurationint0msdelay before idle
minTargetLockTimeint0mslock-on time (converted to ticks)

phasedReloads[] entry:

fieldtypedefaultmeaning
phasestringREQUIREDPREPARING | RELOADING | COMPLETING. Exact upper case
durationintREQUIREDms
animationstringREQUIREDclip name in the animation file
conditionconditionalwayspicks between entries of the same phase (see Conditions)
shakeEffectsobject[]none[{start REQUIRED ms, duration REQUIRED ms, initialAmplitude=0.15, rateOfAmplitudeDecay=0.3, initialAngularFrequency=1.0, rateOfFrequencyIncrease=0.01}]

Aiming and HUD#

fieldtypedefaultmeaning
aimingEnabledbooltrueallow aiming. Also creates the default Aiming feature
aimingZoomnumber0.05zoom of the default Aiming feature (0.25 = 25%). Ignored if an explicit Aiming feature is given
aimingCurveX/Y/Znumber0.0 / -0.07 / 0.3aim-down-sights position curve
aimingCurvePitch/Yaw/Rollnumber-0.01 eachaim rotation curve
scopeOverlaystringnonefull texture path, e.g. "thematic:textures/gui/x.png" (no namespace → thematic); a flat texture that covers the screen while aiming
targetLockOverlaystringnonetexture path
targetLockedSound, targetStartLockingSoundstringnonesound keys

Recoil and handling (camera feel; unitless unless noted)#

fieldtypedefaultmeaning
viewRecoilAmplitudenumber1.0camera kick size
viewRecoilMaxPitchint20max accumulated camera pitch (degrees, unverified)
viewRecoilDurationint100ms
shakeRecoilAmplitudenumber0.5screen shake size
shakeRecoilSpeednumber8.0screen shake speed
shakeDecaynumber0.98shake falloff per step
shakeRecoilDurationint400ms
gunRecoilInitialAmplitudenumber0.3model kick size
gunRecoilRateOfAmplitudeDecaynumber0.8
gunRecoilInitialAngularFrequencynumber1.0
gunRecoilRateOfFrequencyIncreasenumber0.05
gunRecoilPitchMultipliernumber1.0
gunRecoilDurationint500ms
gunRandomizationAmplitudenumber0.01idle sway
idleRandomizationDurationint2500ms
recoilRandomizationDurationint250ms
shotsPerRecoilint1play recoil every N shots
jumpMultipliernumber1.0jump bob scale
bobbingnumber1.0walk bob. Clamped 0–2
bobbingOnAimnumber0.3walk bob while aiming. Clamped 0–2
bobbingRollMultipliernumber1.0clamped 0–10
sustainedRecoilbool or objectoffclimb under held fire. true = defaults. Object keys: pitchIncrement=0.0025, posYIncrement=-0.0125, posZIncrement=0.01, yawMultiplier=0.05, pitchCap=0.01, posYCap=-0.05, posZCap=0.08, onAim (bool, default false), decayTime=400 (ms)
sustainedRecoilPitch, sustainedRecoilPosY, sustainedRecoilPosZ, sustainedRecoilYawMultiplier, sustainedRecoilPitchCap, sustainedRecoilPosYCap, sustainedRecoilPosZCap, sustainedRecoilOnAim, sustainedRecoilDecayTimeflat legacy spellings-same settings. Read after the object, so they win. Any increment key switches sustained recoil on

Sounds#

fieldtypedefaultmeaning
fireSoundstringnone (silent)sound key
fireSoundVolumenumber5.0
headshotSoundstringbuilt-insound key
headshotSoundVolumenumber3.0
reloadSoundstring-parsed and IGNORED. Put reload sounds in the animation file

Unknown sound keys do not error: the key is registered on the fly and plays nothing unless it exists in a sounds.json.

Effects, glow and rotation#

fieldtypedefaultmeaning
effectsobject[]none[{phase, name}]. phase = FirePhase (below). name = effect name. FIRING-phase effects of type MUZZLE_FLASH feed the default MuzzleFlash feature. Omitting phase = the effect never fires
featuresobject[]nonesee Features. An explicit feature replaces the default one of the same type
glowingPartsobject[]none[{name REQUIRED bone, texture?, direction? (NORTH/SOUTH/EAST/WEST/UP/DOWN), sprites?{rows=1, columns=1, fps=60, type=LOOP}}]. Emissive bone. Phases come from a gun-level phases string array (default ANY), not per part
rotationsobject[]nonespinning bone (minigun barrel): [{modelPart bone, phase "fire"|"reload" (default "fire", lowercase exact), rpm=180, acceleration=1.0, deceleration=5.0}]

FirePhase values: PREPARING, FIRING, COMPLETETING (sic, misspelled in code), HIT_SCAN_ACQUIRED, HIT_TARGET, ANY, FLYING.

Parsed but currently no effect (verified: no code reads them)#

animationType (RIFLE|PISTOL; still must be a valid value), thirdPersonFallbackAnimations, hitScanSpeed, hitScanAcceleration, shotsPerTrace, reloadSound.


Misc item ("type": "MISC")#

fieldtypedefaultmeaning
namestring= file nameoptional; must equal the file name
rendererobjectnone{"type":"model"} → geo renderer on <ns>:geo/item/<id>.geo.json

Features (features: [{ "type": ..., ... }])#

  • Every feature except PartVisibility accepts an optional condition (see Conditions); the feature applies only while the condition holds.
  • One feature per type per item. A later entry of the same type replaces the earlier one.
  • Defaults the engine adds when absent: ActiveMuzzle, FireMode (built from top-level fireModes/damage/rpm/maxAmmoCapacity/burstShots/pelletCount/pelletSpread/maxShootingDistance), MuzzleFlash (from top-level FIRING MUZZLE_FLASH effects), Reload, Aiming (if aimingEnabled).
typefieldsmeaning
AccuracyaccuracyModifier number REQUIREDspread divided by this (>1 = tighter)
Aimingzoom=0.1, viewBobbing=1.0aim zoom (0.25 = 25%)
DamagehitScanDamageModifier number REQUIREDmultiplier on hit-scan damage
FireModefireModes: object[] (below)replaces the default fire modes. Each mode still inherits the gun's top-level stats it doesn't set
AmmoCapacityammoCapacityModifier int (multiplier) OR ammoCapacity int (absolute)magazine change. The modifier is an integer, so ×1.5 is impossible
ReloadmaxAmmoPerReloadIteration intoverrides the top-level value
MuzzleFlasheffects: [{phase, name, condition?}]muzzle flash effects. Replaces those derived from top-level effects
PartVisibilityparts: [{name bone, visible=true, condition}]show the bone when the condition holds (hide it when visible:false). No feature-level condition
RecoilrecoilModifier number REQUIREDrecoil multiplier
SoundfireSounds: [{sound REQUIRED, volume=5.0, condition?}], OR fireSound REQUIRED + fireSoundVolume=5.0replaces the fire sound (e.g. when suppressed)
Skintexture REQUIREDalternate gun texture
BeamcoreWidth=0.05, glowSize=0.35, haloLayers=8, coreColor=0xFFFFFF, glowColor=0xFFFFFF (int or "#RRGGBB"), opacity=0.7, maxRange=64.0 (blocks)continuous beam drawn from the muzzle while firing (see data/thematic/guns/cold_gun.js)
ActiveMuzzleparts: [{name bone, condition?}]which muzzle bone effects spawn from
DualWieldtwin? (none)the gun may go in the off hand: with one in each hand, attack fires the off-hand gun and use fires the main-hand one (no aiming). In first person the off-hand gun is drawn as the mirror image of its model, gripped by the left arm, and its ammo reads out left of the hotbar. twin: "<gun id>" makes a gun that is always one of a pair: held in the main hand it brings that gun into the off hand (whatever was there is tucked away and comes back when the pair is put down), and it can not go in the off hand itself

FireMode.fireModes[] entry. A key the mode leaves out falls back to the gun's top-level key of the same name when the gun sets one (damage, maxAmmoCapacity, maxShootingDistance, rpm, burstShots, pelletCount, pelletSpread), otherwise to the default below:

fieldtypedefaultmeaning
namestringREQUIREDmode id, used by the selectedFireMode condition
displayNamestring= namelang key / text
typestringSINGLESINGLE | AUTOMATIC | BURST
rpmintgun rpm, else 600
burstShotsintgun burstShots, else 3
maxAmmoCapacityintgun maxAmmoCapacity, else 1magazine size for this mode
damagenumbergun damage, else 5.0HP points per hit
maxShootingDistanceintgun maxShootingDistance, else 200blocks
pelletCount / pelletSpreadint / numbergun values, else 0 / 1.0
isUsingDefaultMuzzlebooltrue
shakeRecoilAmplitude/shakeRecoilSpeed/shakeRecoilDurationnumber/number/int ms0.5 / 8.0 / 400per-mode view shake; applies only if at least one is set
animationNamestringanimation.model.firefire clip
fireAnimations[{name, condition?}]-replaces animationName
prepareFireAnimations, completeFireAnimations[{name, duration REQUIRED ms, condition?}]-
enableFireModeAnimations[{name, duration REQUIRED ms}]-condition is read from the enclosing feature, not the entry
effects[{phase, name, condition?}]-active only while this mode is selected

Conditions#

A condition is a JSON object with ONE key, or a string.

  • Logic: {"anyOf":[...]}, {"allOf":[...]}, {"not":{...}}. anyOf items must be objects; allOf items may be strings.
  • Fire mode: selectedFireMode / unselectedFireMode (fire-mode name).
  • State: isGunInHands (bool; false = on the ground), isUsingDefaultMuzzle (bool), ammoCount (int, exact equality).
  • Reload: onEmptyReload (true = reload iteration index 0, false = index > 0; it tracks the reload iteration, not whether the mag is empty), preparingReload (true = before the first shell, false = after), reloadIteration (int index).
  • String forms: "random" (random pick), or a comparison "<var> <op> <number>" with var ammoCount | reloadIterationIndex and op < <= > >= == (e.g. "ammoCount <= 3"). The var may be taken modulo a whole number first: "ammoCount % 2 == 0" is every other round, which is how a pair of guns in one model alternates muzzles and fire clips.
  • An unknown key throws, and the item fails to load.
  • Conditions cannot see the player, and results are cached per item NBT.

Effects (data/<ns>/gun_effects/*.json)#

  • One effect per file. Its name is the file name (y for thematic, <ns>:y otherwise; see Ids).
  • Registered into a global effect table shared by all packs and the built-ins. All effects load before any gun and are validated by parsing once at load.
  • Referenced from: gun effects[].name, MuzzleFlash feature effects[].name, and fire-mode effects[].name.
  • Resolution is lazy, so an effect from another pack works if that pack loads.
fieldtypedefaultmeaning
namestring= file nameoptional; must equal the file name. Clashing with a built-in effect name → this file fails to load
typestringREQUIREDMUZZLE_FLASH | FIRE_MUZZLE_FLASH | IMPACT | TRAIL | ATTACHED_PROJECTILE | DETACHED_PROJECTILE | GLOW_BEAM
texturestringREQUIREDfull path, e.g. "thematic:textures/effect/muzzle_yellow.png" (no namespace → thematic)
durationint1000ms
delayint0ms (unverified unit)
blendModestringNORMALNORMAL | ADDITIVE | NONE
depthTestbooltrue
glowboolfalsefull-bright
brightnessint1
numRotationsnumber0spins over the lifetime
initialRollnumberrandom 0–360degrees
colorint16777215 (0xFFFFFF)decimal int only. A "#hex" string fails, unlike Beam
alpha, widthnumber or interpolator1.0interpolator = {"type":"LINEAR","startValue","endValue"} | {"type":"EASE_IN"|"EASE_OUT"|"EASE_IN_EASE_OUT"|"EASE_IN_EASE_OUT_SHARP","value"=1} | {"type":"EASE_IN_EASE_OUT_2","value"=1,"fadeIn"=0.01,"fadeOut"=0.99}
spritesobjectnonesprite sheet {rows=1, columns=1, fps=60, type=LOOP (STATIC|RANDOM|LOOP|PLAY_ONCE)}

Type-specific fields:

effect typevalid phasesextra fields
MUZZLE_FLASHFIRING-
FIRE_MUZZLE_FLASHFIRING- (thematic:muzzle_fire is one)
IMPACTHIT_TARGETcount (a number, or {"type":"DAMAGE_BASED","maxCount","maxDamage"}), impactWidth (a number, or {"maxHitSize","maxWidth"})
TRAILFLYING (projectile effects)longitudeOffset=0
ATTACHED_PROJECTILE, DETACHED_PROJECTILEPREPARING, FIRING, HIT_SCAN_ACQUIRED, COMPLETETINGface{minU=0,maxU=1,brightness=1,width?}, blade{count=1,minU=0,maxU=1,brightness=1,width?} (width may be an interpolator), initialSpeed=0, acceleration=0 (both 0 = static beam), segments, bladeLength
GLOW_BEAMthe beam's phasescoreWidth, glowSize, haloLayers, coreColor (decimal int) (thematic:ice_beam is one)

Using an effect in a phase its type doesn't support throws when the gun fires. That is a runtime error, not a load error.

Shipped effect names, usable from any pack as "<name>": muzzle_red, muzzle_blue, muzzle_yellow, bullet_red, bullet_blue, bullet_yellow, muzzle_flash, muzzle_flash_small, muzzle_flash_big, muzzle_flash_big_ex, muzzle_fire, tracer, tracer_025, tracerminigun, ice_beam, laser, laser_muzzle, launcher_muzzle, javelin_muzzle, explosion, explosion_big, debris, chem_trail, grenade_trail, javelin_trail, rocket_exhaust_plume, blood, bloodpurple, bloodgreen, bloodblue, blood_mist, blood_mist_purple, blood_mist_green, blood_mist_blue. They are ordinary files (data/thematic/gun_effects/*.js), most of them in the heroes pack jar, so a gun naming one of those needs that pack installed. The engine jar keeps the blood effects, explosion, debris, grenade_trail, muzzle_fire, bullet_yellow and muzzle_yellow. A pack file of the same name in another source is a collision: the first source keeps the name and /thematic pack validate names both files.

Example (testpacks/wilfred/data/thematic/gun_effects/stage_flash.json, used as "stage_flash"):

{
  "type": "MUZZLE_FLASH",
  "texture": "thematic:textures/effect/muzzle_yellow.png",
  "duration": 80,
  "width": 1.6,
  "brightness": 1,
  "glow": true
}

Minimal gun#

data/mypack/guns/my_pistol.json → item mypack:my_pistol:

{
  "maxAmmoCapacity": 12,
  "fireModes": ["SINGLE"],
  "damage": 8.0,
  "rpm": 300,
  "fireSound": "tommygun_fire",
  "reloadAnimation": "animation.model.reload",
  "reloadCooldownTime": 1500,
  "effects": [{ "phase": "HIT_SCAN_ACQUIRED", "name": "bullet_yellow" }]
}

Plus:

  • assets/mypack/geo/item/my_pistol.geo.json
  • assets/mypack/textures/item/my_pistol.png
  • assets/mypack/models/item/my_pistol.json (builtin/entity + display)
  • assets/mypack/lang/en_us.json → "item.mypack.my_pistol": "My Pistol"
  • optional: assets/mypack/animations/item/my_pistol.animation.json with animation.model.reload, animation.model.fire

A gun that reuses another's art needs only the JSON (with "model": "thematic:<other gun>"), the item model file, and the lang line. Every field left out takes the default from the tables above.

Full gun (reuses a model, shell-by-shell reload, custom effect, conditional features)#

data/thematic/guns/wilfreds_prop_shotgun.json → item thematic:wilfreds_prop_shotgun (an extended version of the Wilfred test pack's gun):

{
  "type": "GUN",
  "_comment": "Reuses alfreds_shotgun's geo/animation/texture. Needs only models/item/wilfreds_prop_shotgun.json + a lang line.",
  "model": "alfreds_shotgun",
  "maxAmmoCapacity": 6,
  "chamberCapacity": 1,
  "maxAmmoPerReloadIteration": 1,
  "pelletCount": 30,
  "pelletSpread": 0.1,
  "damage": 1.5,
  "maxShootingDistance": 40,
  "rpm": 68,
  "fireModes": ["SINGLE"],
  "fireSound": "shotgun_fire",
  "fireSoundVolume": 4.0,
  "gunRecoilInitialAmplitude": 1.3,
  "shakeRecoilAmplitude": 2.0,
  "shakeRecoilSpeed": 2.0,
  "viewRecoilAmplitude": 10.0,
  "bobbing": 1.2,
  "sustainedRecoil": { "pitchIncrement": 0.004, "decayTime": 500 },
  "effects": [
    { "phase": "HIT_SCAN_ACQUIRED", "name": "tracer" }
  ],
  "features": [
    { "type": "MuzzleFlash", "effects": [{ "phase": "FIRING", "name": "stage_flash" }] },
    { "type": "Aiming", "zoom": 0.25 }
  ],
  "phasedReloads": [
    { "phase": "PREPARING", "condition": { "preparingReload": true },  "duration": 530,  "animation": "animation.model.prepareempty" },
    { "phase": "PREPARING", "condition": { "preparingReload": false }, "duration": 450,  "animation": "animation.model.prepare" },
    { "phase": "RELOADING", "condition": { "onEmptyReload": true },    "duration": 1200, "animation": "animation.model.loadchamber",
      "shakeEffects": [{ "start": 0, "duration": 400, "initialAmplitude": 0.2 }] },
    { "phase": "RELOADING", "condition": { "onEmptyReload": false },   "duration": 700,  "animation": "animation.model.load" },
    { "phase": "COMPLETING", "duration": 600, "animation": "animation.model.finish" }
  ]
}

How errors surface#

  • Run /thematic pack validate (op). It prints a guns and a gun_effects line (N loaded, M failed), every failing file with its id, path and the field at fault, and yellow warnings for unknown fields, a name that doesn't match the file, duplicate ids across packs and old thematic/gunpacks folders. Details are also in logs/latest.log.
  • A failing file is skipped; the game and every other gun keep loading.
message (validate / log)cause
field "type": expected one of GUN (the default), MISC; found "PISTOL"bad item type
field "maxAmmoCapacity": required for a GUN ...missing magazine size
field "phasedReloads[1].phase": expected PREPARING, RELOADING or COMPLETING (exact upper case)lower-case or unknown reload phase
field "features[0].type": expected one of Accuracy, Aiming, ...unknown feature type
field "texture": required ... (gun effect)effect without a texture
unknown field "dammage" (ignored); did you mean "damage"? (warning)typo; the default applies until fixed
field "name": "x" doesn't match the file name "y" (warning)rename the file or drop name
gun <id>: defined twice, in <file A> and <file B> (warning)two packs use the same id; the first wins
gun_effects thematic:<name>: two files claim this id, ...two effect files of one name; see Ids
<file>.js:<line>: <method>(): ...a script called something wrongly; the message says what it expected
defined but not loaded ...added after startup, or in a world datapack: restart / move it into a pack
IllegalArgumentException: ..., No enum constant ..., Required property 'x' is not defineda value the engine's parser rejects (bad enum, unknown condition key, missing required nested field)
Registered N gun item(s) and M gun effect(s) ... (INFO, log only)success summary

Not caught per file (these run later; expect a runtime or startup error, unverified): a FIRING effect naming an unknown effect (resolved while building the default MuzzleFlash feature).

Common mistakes#

  • Old layout. Files under thematic/gunpacks/<pack>/items/ are not loaded. Move them to data/<ns>/guns/ (see "Moving from the old layout") and delete pack.json.
  • Id from the wrong place. The id is the FILE NAME. Renaming the file changes the id (and removes the item from existing worlds); a name field can't override it.
  • Missing namespace in references. Items and effects outside thematic are referred to as "<ns>:<name>" everywhere (effects[].name, ...). A bare name only finds thematic content.
  • Required fields. A GUN needs maxAmmoCapacity; an effect needs type and texture.
  • Case-sensitive values.
    • phasedReloads[].phase must be exactly upper case ("RELOADING", not "reloading").
    • rotations[].phase must be exactly "fire" or "reload".
  • model reuse.
    • Expecting model to cover the item model: it doesn't. You still need assets/<ns>/models/item/<id>.json and a lang entry.
    • Animation names in phasedReloads etc. must exist in the reused gun's animation file.
  • Asset paths.
    • Assets in the wrong namespace: everything (geo, animation, texture, item model, lang) goes under the same <ns> as data/<ns>/guns/. Only new sound ids go in assets/thematic/sounds.json.
    • Short texture names don't resolve: texture fields (effects, overlays, glow, skin) need the full path with textures/ and .png.
  • Effects.
    • Missing phase in a gun effects entry: the effect never plays (validate warns).
    • Naming an effect file after a shipped effect: two files claim one name, and the first source keeps it.
    • Effect color as "#RRGGBB": fails. Use a decimal int. (Beam coreColor/glowColor do accept hex strings.)
  • Unused keys. reloadSound, animationType, hitScanSpeed, shotsPerTrace do nothing. Don't tune them.
  • Restart and sync. Editing a gun and expecting /reload to apply it: guns load once at startup, so restart. Server and clients need the same packs.

Armor clips in first person#

A suit clip is written for the body, with the arms in the body's own frame (rotation = the ModelPart's X/Y/Z). The wearer's own first-person view draws only the arms, in the hand's frame, so a clip's arm pose is converted to point the same way relative to the camera: an arm raised forward points along the view from the lower right (or left) of the screen, one raised overhead rises into view, and an arm hanging, pointing across the camera or swung behind the back stays in the vanilla first-person pose rather than leaving the screen. Every controller's pose goes through this (triggered, held, flight, climbing, block), and the position channel is converted too, with the arm kept from lunging into the camera.

An animation entry can say how its arms are drawn in first person with "first_person":

valuemeaning
"convert" (default)the arms are converted from the body's frame to the hand's frame
"rest"the arms keep the vanilla first-person pose while the clip plays (for a clip whose arms mean nothing from the wearer's eyes)
"raw"the clip's numbers are used as written, in the hand's own frame

A clip that turns the whole body (armorBody rotating 30 degrees or more at any keyframe: dodges, flips, kicks) is converted only partly, since the camera does not follow the body; "first_person": "convert" turns that off. A clip written for the hand can also be named per move: the first_person option of the animation action (animation("shoot_missile", { firstPerson: "shoot_missile_fp" })) or triggerSplitAnimation, and that clip is drawn raw. Mirrored clips swap arms in first person too. -Dthematic.firstPersonArms=raw draws every clip unconverted, for comparing.

Flight profiles — data/<namespace>/flight_profiles/<any folders>/<id>.json#

How a suit flies, as data. flight_type used to be the whole of it: six values that picked an arm pose, with every other number — hover speed, how hard fast flight shoves, how far the body lays over, when the speed lines come in — shared by every suit in the game. A profile is that set of numbers, named by a suit with flight_profile, and the six old values ship as six profiles. A suit that names none flies exactly as it did, so you only write one when you want a character to fly differently rather than merely to look different doing it.

Profiles are reloadable and inherited: parent is another profile, your file is the differences, and /reload picks up an edit without a restart. Almost every profile you write should be four lines.

{
  "parent": "thematic:one_hand_right",
  "_comment": "Heavy, and she does not hover: a dead stop in mid-air is out of character.",
  "hover": { "speed": 0.09 },
  "fast": { "speed": 0.5, "acceleration": 0.25 },
  "lean": { "maxTilt": 26.0, "springIn": 0.18 }
}

The physics itself stays in Java. A profile supplies the numbers it runs on, the clips it plays and the thresholds it compares against; it cannot change the maths. Flight runs on the client and the server at once and the two have to agree to the tick, so this is the one system where a pack getting it wrong would desync a player rather than just look wrong.

hover — creative flight, the slow half#

fielddefaultmeaning
speed0.175hover speed. 0.175 is Thematic's normal and the ratio to it scales the fly speed (clamped ×0.4…×2.5).
enabledtruefalse for a flyer who cannot hover at all — no creative flight, only fast flight
settleTicks5ticks of hovering before the hover clip takes over from a take-off or an exit

fast — fall-flying, the sprint-into-the-sky half#

fielddefaultmeaning
speed0.375total fast-flight speed, in the same unit; clamped ×0.4…×3
canBoosttruefalse = hover only, no fast flight at all
sprintToEntertruewhether sprinting in mid-air takes off on its own
airborneTicks20ticks airborne first. A sprint jump is airborne for about twelve, so much under twenty catches the top of an ordinary jump
entrySpeed1.0blocks per tick of horizontal speed at which a hovering flyer is handed over to fast flight
acceleration0.4how hard the velocity is pulled toward the target each tick
maxSpeed1.5the target's length, before the speed multiplier
responsiveness0.1how much of the look direction is added outright each tick — what makes a turn bite rather than arc
clampMin, clampMax0.5, 14.0floor and ceiling on the suit's own speed stat's contribution
graceTicks2ticks the pose outlives the fall-flying flag for players you are only watching, so one blanked sync doesn't restart every clip
hoverRestoreTicks20ticks the hand-off back to hover keeps re-asserting itself after fast flight ends
turnRate0degrees per tick the thrust direction may swing toward the look, from the way the flyer is really going. 0 is unlimited. A small value (3 to 5) makes a heavy flyer: it drifts wide through a turn and a reversal is a long arc. Lower acceleration and responsiveness as well for a slow spool-up

lean — how far the body lays over#

The lean is a function of speed while hovering, so a flyer is already leaning by the time fast flight starts and fast flight only carries the same curve to full. That is what keeps a take-off continuous.

fielddefaultmeaning
maxTilt18.0degrees of steering roll and pitch a strafe reaches
springIn0.3how quickly the lean deepens (about 16 ticks from a standing start)
springOut0.42how quickly it eases back (about 12)
startSpeed0.45blocks per tick at which the hover lean begins. Must be below fullSpeed
fullSpeed1.3blocks per tick at which it reaches its cap
hoverCap0.4how far over hovering alone may carry the body; fast flight takes it the rest of the way
backwardLean18.0degrees of lean back when hovering backward
travelFollow0.3per-tick follow rate of the heading toward where the flyer actually moved
stepTicks00 eases continuously. Above 0 the lean target is only re-read every this many ticks and held between, so attitude corrections arrive as discrete steps (a servo look)
snapDegrees0above 0, the fast-flight pitch and bank are rounded to multiples of this many degrees, so the body clicks between attitudes
fastPitchBias0degrees further nose-down than lying along the travel path in fast flight. A jetpack flyer pitches over a little past level (10 to 15)

poses — the clips, which is what flight_type really chose between#

fielddefaultmeaning
introsuper.flight.nohandplayed once when fast flight starts, then held on its last frame
loopsuper.flight.nohand.looptakes over from the intro, or is the whole of fast flight when there is no intro
toHoversuper.flight.nohand.exitplayed when fast flight ends in mid-air
toRestsuper.flight.nohand.exitplayed when it ends on the ground
hoverIdlesuper.flight.idlethe hovering float
glideglide.flight.idlefall-flying without the flight ability
blendTicks3ticks blended between them
styledefaultpropelled for a thruster flyer: legs straight and parallel and trailing as one piece, arms stiff against the sides, the body leaning into its travel. Anything wearing the suit flies this way, a squad flier included
legSpread0propelled only: degrees each leg splays out from the other. 0 is feet together under the hips
legTilt16propelled only: degrees both legs, together, trail back from the hip at full speed (a quarter of it at rest). A machine wants 5 to 8
bob2.5propelled only: degrees of gentle leg bob. 0 for a rigid machine
armBack14propelled only: degrees the arms sweep back at full speed (positive is back)
armOut2propelled only: degrees the arms stand out from the body while hovering
armHover0propelled only: arm pitch at rest in degrees, before speed sweeps them to armBack (negative is forward)
armTwist0propelled only: degrees each arm is turned about the shoulder (mirrored); with the arms swept back this turns the palms rearward

Give an intro and you are promising a loop: the intro holds its last frame for ever and the loop is what takes it from there, so an intro alone freezes the arms mid-flight. To fly on one looping clip with no intro and no exits — which is what the jetpack and winged poses do — clear the intro explicitly:

{ "parent": "thematic:default", "poses": { "intro": null, "toHover": null, "toRest": null, "loop": "my_pack.jet" } }

Leaving the key out inherits the parent's intro instead, which is not what you meant.

effects#

fielddefaultmeaning
speedLineLean0.55lean below which no speed lines appear. A lean and not a speed: a cruising hover leans a little, a committed fast flight leans fully
speedLineSpeed1.4blocks per tick at which the line rate tops out
speedLineRate0expected lines per tick at full lean and full speed. 0 (the default) has no speed lines; give a profile a rate above 0 to turn them on
boomTicks100ticks of unbroken fast flight before the sonic boom. 0 for a flyer who never booms
boomSoundsonic_punchthe Thematic sound played at that moment
jets[]emitters, each firing when its when says (below)
antiGravnonean anti-gravity look instead of fire: a pulsing pad of light under each boot, a ripple while hovering, a faint streak at speed (below)

A jet is {"anchor": …, "particle": …, "count": 1, "speed": 0.0}, with anchor one of head, body, rightArm, leftArm, rightLeg, leftLeg, rightFoot, leftFoot, rightPalm, leftPalm (the palm of each hand) and particle a parameterless particle id. aim is "down" (the default: the flame points down from the anchor) or "palm" (it points out of the palm: out to its side, down and back). offset [right, up, forward] in model pixels (1/16 block) moves the jet off its anchor, and angle [pitch, yaw, roll] in degrees turns it after aim: roll first (+ carries up toward right), then pitch (+ carries forward toward down), then yaw (+ carries forward toward right). Both are in the body's frame, so they follow the body's turn and lean but not an arm's pose; both default to zero. when says when it fires:

whenfires
alwayswhenever the flyer is airborne under their own power: hovering or fast (the old "hover": true)
hoveronly while hovering
fastonly in fast flight (the default for a jet that says neither when nor hover)
dashonly while the jets are lit by the ignite_jets ability action, flying or not: a dash's thrusters. A dash flame on a body that is on the ground is thrown back along the dash instead of down

minecraft:dust additionally takes "color": "#RRGGBB" and "size". Anything that wears the suit and is off the ground (a squad flier) emits the same jets. Thematic's own generic profiles emit nothing; this is how you give a jetpack its flame.

Lighting the jets from an ability. ignite_jets (script: press.igniteJets({ duration: 0.25 }), seconds, at most 10) puts the marker status effect thematic:jets_lit (the heroes pack ships it; a pack using ignite_jets needs it too) on the wearer, and a when: "dash" jet burns while it lasts; a flame fades out over the last three ticks instead of cutting off. Call it every tick of a longer burn (inside every) so it goes out a moment after the burn ends. Because it is an ordinary status effect it is already synced to everyone who can see the body, so a suit-wearing mob (a sentinel running a mob() block) lights its own jets the same way, and no packet is involved.

antiGrav is an object, every field optional:

antiGrav fielddefaultmeaning
color#FF1F3F#RRGGBB of the pad, the ripple and the streak
size0.3blocks, radius of the pad under each foot
pulse0.40 to 1: how deeply the pad's brightness breathes; 0 is steady
period40ticks one breath (and one ripple) takes
shimmer0.50 to 1: brightness of the ripple spreading under a hovering flyer, fading out as they speed up; 0 leaves it out
streak0.6blocks the faint fast-flight streak behind the feet is long at full speed; 0 leaves it out
{ "effects": {
    "antiGrav": { "color": "#E0152B", "size": 0.32, "pulse": 0.45, "period": 36, "shimmer": 0.5, "streak": 0.7 },
    "jets": [ { "anchor": "rightFoot", "when": "dash", "flame": { "length": 1.2, "hover": 0.5 } } ] } }

Like the flames, it is drawn for other players, in third person, and for anything wearing the suit while airborne. Ultron and his sentinels fly this way (glow always, thrusters only during Thruster Rush); Iron Man keeps blue repulsor flames: always-on boot flames plus a smaller one out of each palm (aim: "palm"), tiny while hovering and long at speed (thematic:repulsor). War Machine keeps orange boot flames (thematic:repulsor_war_machine).

A jet may also carry a rendered flame instead of (or as well as) a particle: a white-hot core inside a coloured outer cone, drawn additively under the anchor with a soft glow at the nozzle. It flickers, stretches with speed and thins as it does, and points along the legs: down while hovering, back and down once the body lies along its travel. It is drawn for other players and in third person, and for anything wearing the suit (a squad flier); the first-person camera does not draw the wearer's own body, so there it sees only the particles.

flame fielddefaultmeaning
core#FFF3B0colour of the inner cone
outer#FF4A1Ccolour of the outer cone and the nozzle glow
length0.9blocks long at rest (up to 2.6 times that at full speed)
width0.12blocks of radius at the nozzle
flicker0.250 to 0.9: how much the length and width jitter each frame
glow10 to 1: brightness of the short, wide heat glow round the nozzle; 0 leaves only the two cones
hover10 to 1: share of length the flame keeps when the flyer is still; it grows with speed to the full stretch (0.2 is a small hover flicker that roars out in fast flight)
{ "anchor": "rightFoot", "when": "always",
  "flame": { "core": "#FFF3B0", "outer": "#FF4A1C", "length": 0.9, "width": 0.11, "flicker": 0.25 } }

A jet with a flame needs no particle. A summon whose squad.expend says "flame": { ... } (the same fields) trails that flame, scaled as large as you like, behind the flier while it charges.

{
  "parent": "thematic:jetpack",
  "effects": { "jets": [
    { "anchor": "rightLeg", "particle": "minecraft:flame", "count": 2, "speed": 0.3 },
    { "anchor": "leftLeg", "particle": "minecraft:flame", "count": 2, "speed": 0.3 }
  ] }
}

landing — the superhero landing#

A suit whose profile has a landing plays a suit animation clip when its wearer touches down hard: a knee and a fist down, held a moment, then the rise. It works for a player and for anything else wearing the suit (a squad flier); the clip is an ordinary GeckoLib armor animation, played the way any move's clip is, so the superhero_landing clip Thematic ships (animations/armor/superhero_landing.animation.json) can be replaced or copied. Server side: the clip, the dust and the thud reach everyone watching.

fielddefaultmeaning
clipsuperhero_landingthe animation clip's name
minFallSpeed0.6blocks per tick the wearer must be dropping at as they touch down (0.1 to 4): about a hard dive out of flight, or a fall of six or seven blocks. The speed fades over a few ticks, so a dive that is pulled out of and eased down does not count
fromFlighttruewhether a descent out of flight counts; false is for plain falls only
dust16particles in the puff thrown up at the feet, 0 for none
soundminecraft:block.anvil.landthe thud, or "" for none
{ "parent": "thematic:jetpack", "landing": { "minFallSpeed": 0.7, "dust": 20, "sound": "minecraft:entity.iron_golem.step" } }

Water, a mount and being teleported mid-fall never count. Iron Man, War Machine and Ultron (and so his sentinels) land this way.

data/thematic/flight_profiles/default.json is Thematic's own flight written out in full — read it to see what every number does, and inherit from it rather than copying it.

HUDs — data/<namespace>/huds/<any folders>/<id>.js or .json#

A HUD is content in its own right, not a set of fields on whatever shows it. A gun's ammo counter and reload bar used to be compiled in and reachable only through fields the gun carried, which meant you could not move the counter without Java. Now a HUD is a list of elements, a gun names one with hud, and most guns name none.

Unlike guns, HUDs are reloadable: /reload re-reads them, so you can nudge placement without restarting.

As a script (huds/<id>.js), the element's type is the method and the thing it shows comes first:

hud(hud => {
    hud.text("{ammo} / {capacity}", { anchor: "BOTTOM_RIGHT", x: -12, y: -22, scale: 1.5, color: "#FFFFFF", when: "notAiming" });
    hud.bar("reloadProgress", { anchor: "BOTTOM_CENTER", x: 0, y: -46, width: 62, height: 3, color: "#FFFFFF", background: "#80000000", when: "reloading" });
    hud.texture("thematic:textures/gui/scope.png", { fit: "COVER", when: "aiming" });
});

Its first two elements as JSON:

{
  "elements": [
    { "type": "TEXT", "text": "{ammo} / {capacity}", "anchor": "BOTTOM_RIGHT", "x": -12, "y": -22, "scale": 1.5, "color": "#FFFFFF", "when": "notAiming" },
    { "type": "BAR", "value": "reloadProgress", "anchor": "BOTTOM_CENTER", "x": 0, "y": -46, "width": 62, "height": 3, "color": "#FFFFFF", "background": "#80000000", "when": "reloading" }
  ]
}
fieldapplies tomeaning
typeallTEXT, TEXTURE or BAR (required)
anchorallwhich corner, edge or centre it hangs from: TOP_LEFT … BOTTOM_RIGHT, CENTER. Default TOP_LEFT
x, yallpixels from the anchor, applied after it — so a negative x on a right anchor insets from the right edge
whenallalways (default), holdingGun, aiming, notAiming, reloading, firing, hasAmmo, outOfAmmo
colorall"#RRGGBB" or "#AARRGGBB". Six digits is opaque
textTEXTthe line, with {ammo}, {capacity}, {fireMode}, {gunName}, {reloadProgress} placeholders. An unknown one fails validation
scaleTEXT1 is the vanilla font
shadowTEXTdrop shadow; true when missing
textureTEXTUREthe image id
fitTEXTUREFIXED (default, needs width/height) or COVER to fill the screen
width, heightTEXTURE, BARpixels
valueBARwhat fills it, 0 to 1. Today: reloadProgress
backgroundBARARGB behind the fill; omit for none

Words may be written as shown (notAiming, reloadProgress) or in upper snake case (NOT_AIMING); both load. data/thematic/huds/gun_default.js in the heroes pack jar is the shipped example — copy it into your pack and move things around. No shipped gun names it: a gun with no hud gets the built-in overlay, which is code, not this file.

Reload phase events#

A phase can make things happen at an offset from its own start:

{
  "phase": "RELOADING",
  "duration": 700,
  "animation": "animation.model.load",
  "events": [
    { "at": 300, "type": "SOUND", "name": "shotgun_shell_load", "volume": 1.0 },
    { "at": 0, "type": "SHAKE", "duration": 180, "initialAmplitude": 0.25 },
    { "at": 500, "type": "PARTICLE", "name": "minecraft:smoke", "count": 3 }
  ]
}
typefields
SOUNDname (required), volume, pitch
SHAKEduration (required), initialAmplitude, rateOfAmplitudeDecay, initialAngularFrequency, rateOfFrequencyIncrease
PARTICLEname (required), count, speed, bone

at is milliseconds from the start of the phase. An event past the end of its phase fails validation — it would simply never fire, and that reads as the event being wrong rather than its timing.

A clip can already carry a sound on a keyframe, and that is the right place for a sound belonging to the motion — the click as a bolt closes. Use an event for a sound belonging to the phase: it survives the clip being retimed or swapped, it can be conditional, and it is timed against the beat the player is waiting out.

shakeEffects is the older spelling of a SHAKE event and still works; both feed one list so a phase's events happen in time order.

Known limit: a PARTICLE event's bone is parsed but not yet honoured — placing a burst on a bone needs the model's baked transform for the frame being drawn. The burst comes off the wielder, so the timing is right even where the position is not.

Suit tags — data/<namespace>/armor_tags/<id>.json#

A suit tag is a bundle of kit that many suits share: a race's weaknesses (kryptonian), a corps' rules (lantern), a powerset (spidey), a stat block (tank_i). A suit lists tag ids in its tags and gets everything each one gives, as if those entries were written in the suit file. Examples: docs/examples/armor_tags/ (armor_tag.minimal.json, armor_tag.full.json).

Built-in tags: the engine ships tank_i, tank_ii, tank_iii, lantern, kryptonian, viltrumite, supe, martian, vampire, speedster and atlantean; the heroes pack ships spidey. List them with unzip -l mods/thematic-*.jar 'data/thematic/armor_tags/*' and read one with unzip -p.

Files and ids#

  • data/<namespace>/armor_tags/<any folders>/<id>.json. The id is the file name; folders only organise.
  • A suit names a tag by bare id ("spidey", looked up as thematic:spidey first, then in any namespace) or in full ("my_pack:wall_crawler"). Name your own tags with your pack's prefix so they can't clash with a built-in.
  • To change a built-in tag, ship a file at the same path (data/thematic/armor_tags/kryptonian.json); it replaces the built-in one completely.
  • Tags are loaded with the other content: /reload picks up edits, and clients receive them from the server.

Fields (all optional)#

fieldtypemeaning
includeslist of idsother tags (or suit ids) whose payload comes first. This file's own entries then add to or replace theirs.
abilitieslist of {"identifier", "keybind", "options"?, "assets"?, "remove"?}exactly as in a suit. Leave options out to keep the ability's own options. "remove": true takes the ability off (from an included tag, or from a suit that applied it earlier).
statslist of {"identifier", "minimum", "maximum"}as in a suit
fighting_typelist of stringsas in a suit; a non-empty list replaces an included one
attributeslist of {"identifier", "effect"}Pehkui scales, as in a suit (pehkui:jump_height 1.7 = 70% higher jumps; pehkui:falling -1 = no fall damage)
cape, gliderobjectaccepted for symmetry with suits, but a suit always sets its own cape and glider (none if it names none), so a tag's never shows

Keys starting with _ are comments.

How a suit applies its tags#

In the order the suit lists them, then the suit's own entries on top:

  • abilities and stats are keyed by identifier: a later entry with the same id replaces the earlier one;
  • fighting_type: the last non-empty list wins;
  • attributes: the first entry for an identifier wins, so a tag's scale beats the suit's own for the same scale type. Don't put a scale in a tag that a suit wearing it needs to change.

An alt ("parent") inherits its parent's tags.

A suit id as a tag#

Naming another suit's id ("tags": ["spiderman"]) gives the whole of that suit's kit: what its own tags give, then its own abilities, stats, fighting types and attributes. It is resolved after every suit has loaded, so file order never matters. A tag file with the same id wins over a suit.

A name with no file#

Is a plain flag, not an error: the suit carries it and code reads it (auto_reload). Nothing is applied.

Minimal tag#

{"abilities": [{"identifier": "weakness_fire_damaging", "keybind": "key.thematic.passive"}]}

Common mistakes#

  • A typo in a suit's tags is silently a plain flag. /thematic pack validate lists tags that loop or includes nothing; ./gradlew validateContent (source repo) checks that tag abilities exist.
  • Expecting a tag's cape to show: the suit's own cape field always wins (see Fields).
  • Two files with one id in different folders: neither loads (ids are file names).

Morph shapes — data/<namespace>/morphs/<id>.json#

A morph is a shape a wearer takes: a body, a hitbox, how it moves, what it holds while worn, and the moves that only exist inside it. A morph ability (bat_form, wolf_form, mist_form) names a shape by id and puts its wearer in it; the client draws the shape's entity over the player, using that entity's own renderer, so there is no model to author.

Shipped shapes: the heroes pack ships Dracula's three — bat, wolf and mist. Read one with unzip -p mods/thematic-*.jar 'data/thematic/morphs/bat.json'. The engine ships none.

Files and ids#

  • data/<namespace>/morphs/<any folders>/<id>.json. The id is the file name; folders only organise.
  • An ability names a shape in full (thematic:bat, my_pack:raven). To change a shipped shape, ship a file at the same path (data/thematic/morphs/bat.json); it replaces that one completely.
  • Loaded with the other content: /reload (or /thematic pack reload) picks up edits, and clients receive them from the server — they need the same table, because the renderer resolves a shape from the id the wearer syncs.
  • A shape that fails to load is simply absent. The ability that names it hands the press back and says so in the log, and every other shape still loads.

Fields (all optional)#

fieldtypedefaultmeaning
entityidnonethe body. The client draws this entity type's renderer over the wearer. Leave it out for a formless shape — nothing is drawn and particle is the whole appearance (that is what mist is).
render_scalenumber1.0multiplier on the drawn model only; the hitbox is separate
hitbox[width, height][1.0, 1.0]factors on the player's own box, not sizes. A player is 0.6 by 1.8, so a wolf's 0.6 by 0.85 is [1.0, 0.47].
eye_heightnumber1.0Pehkui eye-height factor on the player's own 1.62. Bring the eyes down with a low body, or the camera sits above it -- and inside the ceiling of a gap the body fits (a bat's 0.45 is 0.28, a wolf's 0.68 is 0.42).
motionnumber1.0Pehkui motion factor. 1.35 is "quicker than a man".
flighttrue/falsefalsecreative-style flight while worn, taken back on the way out
hands_freetrue/falsetruewhether the shape can still swing and use things. A wolf can; a cloud cannot.
right_clickability idnonethe move an empty-handed right-click on no body presses in the shape; it must be on the suit (the wolf's wolf_stampede).
use_entitytrue/falsefalsewith hands_free false: a right-click onto a body still goes through, for a move that answers use_entity (the bat's neck bite).
breathes_watertrue/falsefalsethe shape ignores drowning
immunetrue/falsefalsethe wearer takes no damage while the shape is worn -- no hit starts, so no knockback either. What ignores invulnerability (/kill, the void) still lands.
passes_throughlist of block ids and #block tagsnoneblocks the wearer moves through as if they were not there: only their collision with the wearer goes, so they still show, stop everyone else and can be clicked. ["minecraft:iron_bars", "#minecraft:fences"]
nbtobject (or SNBT text)noneentity data read into the drawn body, as /summon would ({"CollarColor": 14}). Cosmetic: only the client reads it.
lookslist of {"flag", "nbt", "render_scale"}nonehow the body is drawn while the wearer has a flag set (a script's setFlag): the first entry whose flag is set wins, its nbt read in after the shape's own, its render_scale (if given) in place of the shape's. {"flag": "wolf_stampeding", "nbt": {"AngerTime": 1200}} draws vanilla's angry, red-eyed wolf.
traitslist of {"effect", "amplifier"}nonestatus effects held for as long as the shape is worn and topped up while it is. amplifier is 0-based, as everywhere else: 2 is Resistance III.
abilitieslist of ability idsnonethe moves that exist only inside this shape, in the order the chooser offers them. One entry never shows the chooser. An ability belongs to exactly one shape; a second shape claiming it is reported by /thematic pack validate and keeps it with the first.
sounds{"enter", "exit"}nonethe noise of taking the shape and of giving it back. change/ambient are the older spelling of the same two.
particleidnonethe puff on the way in and out — and the entire body of a formless shape. Must be a particle that carries no data (minecraft:smoke, thematic:thick_smoke).

Keys starting with _ are comments. Any other key fails the file, naming the field and the nearest known one: a shape that silently does not do what it says is worse than one that does not load.

Minimal shape#

{"entity": "minecraft:wolf", "hitbox": [1.0, 0.47], "motion": 1.35}

Full shape (the shipped wolf)#

{
  "entity": "minecraft:wolf",
  "hitbox": [1.0, 0.47],
  "eye_height": 0.42,
  "motion": 1.35,
  "traits": [
    {"effect": "minecraft:speed", "amplifier": 1},
    {"effect": "minecraft:night_vision", "amplifier": 0}
  ],
  "abilities": ["wolf_stampede"],
  "looks": [{"flag": "wolf_stampeding", "nbt": {"AngerTime": 1200}}],
  "sounds": {"enter": "minecraft:entity.wolf.growl", "exit": "minecraft:entity.wolf.whine"},
  "particle": "thematic:thick_smoke"
}

A formless shape (the shipped mist)#

{
  "hitbox": [0.5, 0.25],
  "eye_height": 0.2,
  "motion": 1.6,
  "flight": true,
  "hands_free": false,
  "breathes_water": true,
  "immune": true,
  "passes_through": ["minecraft:iron_bars", "minecraft:chain", "minecraft:glass_pane", "#c:glass_panes",
                     "#minecraft:fences", "#minecraft:fence_gates", "#minecraft:walls"],
  "traits": [{"effect": "minecraft:water_breathing", "amplifier": 0}, {"effect": "minecraft:slow_falling", "amplifier": 0}],
  "sounds": {"enter": "minecraft:block.fire.extinguish", "exit": "minecraft:entity.illusioner.mirror_move"},
  "particle": "thematic:thick_smoke"
}

Common mistakes#

  • Writing hitbox as sizes. They are factors: [1.0, 1.0] is a player-sized shape, not a 1-block one.
  • Naming an entity that is not installed. The file fails to load and says so with the id — check the spelling and that the mod that owns the entity is present.
  • Expecting a shape to appear on a suit. Suits list the ability (bat_form), not the shape; the ability names the shape. The shape's own abilities are never listed on a suit — they are reached inside it.
  • Giving two shapes the same entry in abilities: the second loses it, because a form ability's cooldown is ticked by the shape that owns it and two owners would tick it twice.
  • Shrinking the hitbox a long way and leaving eye_height at 1: the wearer's eyes stay at a man's height, above the body, and the camera ends up in the ceiling of every gap the body slips through.

What the morph ability adds#

The ability that wears a shape ({"type": "thematic:morph", "morph": ...}) takes two more optional fields, for what belongs to taking the shape rather than to the body:

  • visual: client visuals drawn while the shape is worn, from the same shelf and in the same shape as a script's visual -- a name, {"id": name, ...options} or a list. The heroes pack's dracula/bat_form.json gives the bat a red thematic:vision (every living thing outlined through walls) and a faint red thematic:screen_overlay.
  • on_enter: actions run on the wearer as they take the shape, with the ability's tuned numbers: [{"type": "thematic:heal", "amount": {"tuned": "heal"}}] (Mist Form's 5 health back, its heal row).

Machine recipes — data/<namespace>/recipes/<id>.json#

One recipe shape for every Thematic machine: the alloy smelter, the centrifuge and the plate press.

{
  "type": "thematic:machine_recipe",
  "recipe_type": "thematic:alloy_smelting",
  "inputs": [{ "id": "thematic:magnesium_ingot", "Count": 3 }, [{ "item": "thematic:hardened_steel" }, { "item": "thematic:steel_ingot" }]],
  "result": { "item": "thematic:adamantium", "count": 1 },
  "experience": 1.5,
  "time": 300
}
fieldtypedefaultmeaning
recipe_typeidrequiredthe machine: thematic:alloy_smelting (2 input slots, fuelled, 200 ticks), thematic:centrifuging (3 slots, 200) or thematic:plate_press (1 slot, 55)
inputslistrequiredone per slot, at most the machine's slot count, matched in any order; each a vanilla ingredient (an item, a tag, or a list of either), an item stack {"id", "Count"} when the count should show in the recipe viewer (the machine still takes one), or null for an empty slot
resultobjectrequired{"item", "count"} or {"id", "Count"}
experiencenumber0
timeticksthe machine's

The older forms still load: "type": "thematic:alloy_smelting" with input1 / input2 / cookTime, thematic:centrifuging with input1..input3 / processTime, thematic:plate_pressing with ingredient. A broken recipe file is logged and skipped like a vanilla one. Machine recipes stay out of the vanilla recipe book (a recipe viewer shows them). Examples: docs/examples/recipes/machine_recipe.minimal.json, machine_recipe.full.json.

Pocket dimensions — data/<namespace>/pocket_dimensions/<id>.json#

A world a wearer is sent into for a while and then brought back from (the heroes pack's speed_force, the engine's quantum_realm). The file name is the pocket's id. Send someone in from a pack with the thematic:pocket action ({"type": "thematic:pocket", "mode": "enter", "pocket": "mypack:realm"}; "mode": "exit" brings the owner back, so a block's on_use can be the way out), or with /thematic dev pocket enter <id> while testing. The dimension itself stays vanilla data (dimension/<id>.json and its dimension_type, whose coordinate_scale scales the walk back out). Server-only; reloaded with /reload.

Pockets can share a world. A pack that wants its own place should reuse thematic:speed_force and pick a spawn far from the others (the Speed Force is at 0, 0; the Quantum Realm is thousands of blocks out). If two pockets overlap, the server logs a warning naming both on load. A pocket with cells is built where players walk, never rebuilt, and shared by everyone inside.

fieldtypedefaultmeaning
dimensionidrequiredthe world the pocket is
spawn[x, y, z][0, 64, 0]where the wearer lands
stay{min, max}{800, 1000}ticks until the wearer is brought back, rolled on entry
corridor{length, radius, floor, wall}nonea corridor along Z rebuilt on every entry (length 512, radius 6 by default)
resident{entity, name, distance, delay, floor, marker}nonesomeone waiting on a platform distance blocks down the corridor, made delay ticks after entry
cells{shells, core, ...}nonehollow glass atoms on a grid (membranes, giant, orbitals, nucleon, electron, seed...)
gravitynumber1the pocket's pull as a fraction of the overworld's; applied by the wearer's client
look{shader, pulse, motes, fog, near}nonehow it looks inside, drawn by the wearer's client: a post pass, a heartbeat, drifting specks, fog colour

Every field: docs/content-types.md. Examples: docs/examples/pocket_dimensions/ (pocket_dimension.cells.json needs only vanilla blocks). A pack ships what the pocket names: blocks under data/<ns>/blocks/, a look.shader under assets/<ns>/shaders/post/ with its program, and any resident entity.

Loot injections — data/<namespace>/loot_injections/<id>.json#

Adds pools to existing loot tables (vanilla's or any mod's) without replacing them. Each pool rolls on its own, so it never displaces what the table already drops.

{
  "tables": ["minecraft:chests/*"],
  "exclude": ["minecraft:chests/spawn_bonus_chest"],
  "pools": [ { "rolls": 1, "entries": [ { "type": "minecraft:item", "name": "thematic:ancient_relic", "weight": 1 } ] } ]
}
FieldMeaning
tablesloot table ids; * matches any run of characters (minecraft:chests/*)
excludeoptional, same syntax; wins over tables
poolsordinary vanilla loot pool JSON (conditions, functions, empty entries, weights)

A malformed file is skipped with a warning naming it in the log; the rest still load. Reloads with /reload.