Addon format
On this page
- What's new in 1.7.0 for pack authors
- Installing and shipping a pack
- Ids: the file name, never the folders
- Reading Thematic's own content: every jar is a pack
- Suits
- Files (id = file name, e.g. night_warden)
- No model? The skin-style default
- Fields (all optional)
- Ladder suits
- Minimal suit
- Collections
- Alts
- Recipes
- Changing an existing suit
- Common mistakes
- Abilities
- File shape
- Make a new ability from an existing one
- Fields worth knowing
- Tuning: the ability_stats JSON
- Loading and reloading
- Checking your work
- Common mistakes
- Ability components — writing the behaviour yourself
- The component file
- Phases — powers that last, tick and react
- Prefabs — parts bundled for reuse
- Mobs: a move a boss can use too
- Ability types by theme
- Throwables
- Fields
- Recipes
- Common mistakes
- Actions
- Generic
- Effects (shared with trick arrows)
- Throwable and gadget
- Examples
- Common mistakes
- Items
- Layout and ids
- Common fields (every type)
- Types
- Errors and validation
- Common mistakes
- Guns
- Writing guns, gun effects and HUDs in JavaScript
- Layout
- Ids
- Where files load from (in this order)
- Restart required
- Moving from the old thematic/gunpacks layout
- Assets a gun needs, and model reuse
- JSON tolerance and validation
- Gun item ("type": "GUN" or omitted)
- Misc item ("type": "MISC")
- Features (features: [{ "type": ..., ... }])
- Conditions
- Effects (data/<ns>/gun_effects/.json)
- Minimal gun
- Full gun (reuses a model, shell-by-shell reload, custom effect, conditional features)
- How errors surface
- Common mistakes
- Armor clips in first person
- Flight profiles — data/<namespace>/flight_profiles/<any folders>/<id>.json
- HUDs — data/<namespace>/huds/<any folders>/<id>.js or .json
- Reload phase events
- Suit tags — data/<namespace>/armor_tags/<id>.json
- Files and ids
- Fields (all optional)
- How a suit applies its tags
- A suit id as a tag
- A name with no file
- Minimal tag
- Common mistakes
- Morph shapes — data/<namespace>/morphs/<id>.json
- Files and ids
- Fields (all optional)
- Minimal shape
- Full shape (the shipped wolf)
- A formless shape (the shipped mist)
- Common mistakes
- What the morph ability adds
- Machine recipes — data/<namespace>/recipes/<id>.json
- Pocket dimensions — data/<namespace>/pocket_dimensions/<id>.json
- Loot injections — data/<namespace>/loot_injections/<id>.json
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 anAGENTS.mdfor 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 athematic:owned_summonentity: formation patterns,engageany|owner_targets,kit,stand_in,expend) and formations (data/<ns>/formations/<id>.js, aplan(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 indocs/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.jsonwithdocs/examples/abilities/dread_aura.full.jsanddread_ward.full.js. Summoned fliers obey the same lift cut and grounding as players. - Cooldowns, sounds and HUD:
cooldownsaddandkeyed;soundheardBy; the target portrait'seffectrows andanchor: "speed_bar". - Animation:
first_personconvert|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 defaultspeedLineRateis 0: a profile that wants speed lines setseffects.speedLineRateabove 0. Example:docs/examples/flight_profiles/jetpack_heavy.full.json. - Projectiles, pulls, doubles: a projectile's
aim: "crosshair";pullmaxRiseandbrake;thematic:owner_copyoptionsdie_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:
| where | what loads | notes |
|---|---|---|
<gameDir>/mods/ | everything | For 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/ | everything | Folders or zips; handy while developing a pack. |
| a Fabric mod jar | everything | A 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>.disabledturns it off in any of the first two places;/thematic pack listshows 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
.jsbeside a.json) claim one id. That is an error that names both files, and neither loads; rename one../gradlew validateContentand/thematic pack validatereport 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 analt/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 tovalidateContent. - 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, seedocs/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)#
| file | what |
|---|---|
data/thematic/armors/<collection>/<id>.json | the definition. The folder name is the collection. |
data/thematic/collections/<collection>.json | the collection card: {"id", "name"} required, plus importance, description, icon, author, version (see below) |
assets/thematic/geo/armor/<id>.geo.json | the Blockbench GeckoLib model, on the 64x64 player-skin UV layout. Optional: see the skin-style default. |
assets/thematic/textures/armor/<id>.png | the texture. <id>_shiny.png is an optional shiny variant. |
assets/thematic/models/item/<id>.json | always {"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>.json | optional 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)#
| field | type | default | meaning |
|---|---|---|---|
wip | bool | true | work 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. |
tier | int | 1 | power tier, 1 to 10 |
abilities | list 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. |
stats | list of {"identifier", "minimum", "maximum"} | [] | thematic:base_generic_attack, thematic:base_generic_speed, thematic:base_defense, thematic:base_utility, each about 0 to 100 |
fighting_type | list of strings | [] | BRAWLER, DUELIST, UTILITY, MAGIC |
tags | list 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_type | string | NO_HANDS | pose 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_profile | id | none | a 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-recipe | id | none | the suit bench recipe, thematic:<recipe file name> |
attributes | list 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 |
parent | id | none | makes this an alt (skin variant) of another suit, e.g. "thematic:night_warden". See Alts. |
guns | list of {"id", "Count"} | [] | guns the suit comes with |
cape, glider | object | none | {"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 |
crafting | object | none | force_rarity (0 starter .. 7 unique), force_shiny (bool), chat_message (announced when crafted), craft_sound (id), unique_ownership (bool, unique rarity only) |
boss, template | bool | false | internal: a boss's suit; a template other suits inherit from without being an item. Leave them out. |
sounds | object | none | the 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:
abilitiesmerge with the parent's: an entry with the sameidentifierreplaces it,"remove": truedrops it, others are added;statsreplace per identifier;tagsandattributesare 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.
idandresult.idare 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_widthandtexture_heightin the.geo.jsonfile. - The suit is missing. Adding a suit needs a restart, not
/reload. The file must also sit in a collection folder underarmors/. - "This armor is a work in progress" and no suit bench.
wipdefaults 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 namedalt/underarmors/. Move it out ofalt/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 inabilities/daredevil/). - Id: the file name,
<id>, for a file underdata/thematic/;<namespace>:<id>for a file under any otherdata/<namespace>/. Usethematicunless 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
thematicability 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_breathreads the entryfrost_breath(written with or withoutmy_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.
- A suit names a
- Every type, its fields and defaults:
docs/ability-types.md(Markdown) anddocs/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#
| field | type | default | meaning |
|---|---|---|---|
type | string | required | the ability type, e.g. thematic:butlers_pride; a bare name means thematic: |
| type fields | whatever the type lists in docs/ability-types.md | ||
_comment | string | none | free text for humans and agents; ignored |
options | object | none | {"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 |
assets | object | none | texture (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:
- Put the id on the suit:
{"identifier": "stage_presence", "keybind": "key.thematic.ability_1"}in the suit'sabilities. - 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). - 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 meansminecraft:. 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}].durationis in ticks (20 per second) andamplifier0 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 } }
}
| field | meaning |
|---|---|
| the key | the ability's id. Only the part after : is used, so thematic:x, my_pack:x and x are one entry |
cooldown | seconds |
duration | seconds: how long a phase with lasts("duration") or a zone with no for lasts, and the default for effect durations |
range | blocks. A script reads this as tuned("radius"); it is the default radius of targets("around"), a cone's length, a beam's reach ... |
damage | health points (2 = one heart), the default for damage() with no number |
amplifier | the default effect level (0 = I) |
disabled | true turns the ability off for everyone (left out is false) |
tuning | every 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 onlytuningsets no stats. - Files are by character:
ability_stats/<character>.jsonfor the moves inabilities/<character>/,shared.jsonforabilities/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")andtuned("amplifier")read the entry, andtuned("radius", 0.5)is half of it.tuned("range")is an error: the field is read asradius. /thematic pack reloadre-reads every file.- Numbers for things that are not abilities (guns, items, entities, prefabs ...) sit in
data/<namespace>/tuning/<name>.jsonas{ "<subject>": { "<key>": <number> } }. Projectiles (arrows, gadgets) aredata/<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) andconvert_content.py(guns, criminals, items, throwables ...) rewrite a script for you;gradlew checkTuningLiterals(part ofcheck) fails on a gameplay number left in a file. tuned(key, times?, otherwise?): any short snake_case key (a-z,0-9,_), scaled bytimes;otherwiseis 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 ispush_strength_2. Inexpr("..."),@keyreads 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 ownerall, and an ability's own row beats it. - Heal, overshield and
tolevels 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 keyheal/overshield, else one hit point. There is nounitsoption; 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) andreset <id> [key]. - The sheet: with Google Sheets on, a worksheet named by
googleSheetsTuningWorksheetName(defaultTuning) holds the same rows asability_id | key | value;/thematic pullreads 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.CHANGEDfires 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/reloador 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;optionsandassetsalways 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):
| command | what it does |
|---|---|
/thematic pack reload | Reloads 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] / off | Watches 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"plusoptions/assets, notype) still work in the same folder. They change an existing ability.
Checking your work#
/thematic pack validatelists 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/, notassets/. - Adding
.jsonto the id in a suit: the suit namesstage_presence, notstage_presence.json. - Leaving the namespace off an ability that is not in
thematic: a file atdata/my_pack/abilities/frost_breath.jsismy_pack:frost_breath, and a suit namingfrost_breathfinds nothing.validateContentsaysdid you mean 'my_pack:frost_breath'?. - Picking an id that already exists (
punch): the built-in wins and your file only applies itsoptions/assets. Use a new id. - Expecting a changed
itemslist to apply on/reload: restart. - Unknown
type: look the type up indocs/ability-types.md. Type names come from the Java class (EquipItemAbilitybecomesthematic: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
abilitieslist 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,holdortoggle; none for a passive), acooldown, agauge,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, tocooldown,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 write | you are handed | it stands for |
|---|---|---|
component(component => ...) | component | the ability: its parts, and its phases |
component.press(press => ...), component.hold(hold => ...), component.toggle(on => ..., off => ...) | the key | what using the key does first, and (then) where it leads |
component.phase("name", phase => ...) | phase | its 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 actions | what happens each time |
phase.on("hurt", { melee: true }, reaction => ...) | reaction | what 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 actions | what happens to each target, or on each branch |
component.use("thematic:armed", options, hit => ...) | whatever the prefab hands | a 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 isfromEvent("amount"); coloured dust isdust([r, g, b], scale). A gameplay number you do need is a key, never a literal:push("target", { strength: tuned("push_strength") })with a rowmy_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 agaugehas 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.jswalks 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 andfor...of; no default parameters, spread, classes or computed keys. Aconstdeclared inside a loop keeps the value of the loop's first pass: useletthere. - 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
tetherpart:component.tether({ line: "swing" | "slingshot" | "hang", entity: "thematic:web_line_entity", poses: "web", clips: {...}, sounds: {...} }). The key still says where it leads (a swing'sholdopens a phase that answersrelease); the tether hears the key after it. The lines are athematic:tetherentity 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:
summonalso takesattributes([{ 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) andoptions(handed to the body as written; worked-out numbers are worked out at the cast).thematic_explosiontakesat: "here" | "spot" | "summoned"andshockwave_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 thrownreturncomponent) andon_returnbring it home.patterntakesyaw_offsetsandshape: "spiral"(turns,radius,height,jitter); anorbit'sstepspreads a group by index. Examples:bolt_options.*.js,pattern_shapes.*.js. - A belt or quiver is a
loadoutpart:component.loadout({ inventory, size?, display?, roster? }), withpress.loadoutCycle(inventory)on its key andpress.loadoutUse(inventory, { autoSwap })on a use move (autoSwapnames the wheel switch that turns swapping on). A wheel switch may start as a game setting instead ofon:wheelSwitch("auto_swap", { on: true, onSetting: "auto_swap_utility" }). Examples:loadout.minimal.js,loadout.full.js. - Moving the wearer and others:
movementalso takescharge: { toward: "locked" | "marked", key, face }(straight at the lock-on or at the bodypress.markTarget(key?)stored),lunge: { ticks, distance | speed, along: "yaw" | "look", keep_vertical },swim_boostandtunnel: { floor, blocked_by }(walk through blocks over a floor);double_jump: { per_level, level, min_level }counts air jumps from a level (none belowmin_level).spin({ who, degrees, ticks })turns a body round;pushtakeson_collide(actions run on the first body it flies into) andcollide_radius;teleportplaces atto: "impact" | "behind_target" | "nudge" | "clear_spot" | "block_top"(each with its room checks and anelse);look_at({ spot }),extinguish({ who }),set_active({ clear_capture });mark_spot({ along: "block_top" }). A fast flight's wind-up runswindup.stages: [{ at: tick | "launch", every, do }], and aglidetakes awhenguard. 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_meterprefab (corp/death_stare.js). - Target guards in a reaction to
hit_landed,attackorkill(marked_by,target: {...}) ask about the body the hit struck, not the wearer's locked target; so do a standingdamage_bonus's and aloot'swhen. - Worked examples:
docs/examples/abilities/brimstone_parry.js(a hold that answers a hit), the heroes pack'sdaredevil/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 indocs/pack-template/data/thematic/abilities/example_suit/. - Still name it in
langand 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}]}
]
}
}
}
partsholds the component's parts by name;phasesholds the named phases, each a map of phase parts. An unknown part is an error naming where it belongs ("slows_cooldownsbelongs on a phase").- The key.
press,holdortoggle, at most one; none makes a passive. Each hassteps, the running order:atis seconds after the key,0(or noat) 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.thenis 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 hasoff_steps,on_equipandbonesof its own. dois a list of actions -- the same ones throwables use (Actions), plus the ones an ability needs:
| type | fields (default) | does |
|---|---|---|
thematic:self_effect | effect (required), duration, amplifier, ambient (false), show_particles (true), show_icon (true) | a status effect on the wearer |
thematic:animation | animation (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_launch | strength (1), lift (0), radius | throws everyone in radius the way the wearer is facing |
thematic:lunge | strength (1), lift (0) | throws the wearer the way they are facing |
thematic:targets | select (around / cone / look / remembered / attacker / victim ...), radius, length, width, height, range, pick (all / nearest / first), at (here / spot), remember, required, do | picks who the move lands on and runs do once aimed at each |
thematic:if | if (a guard), do, else | runs do when the guard holds, else when it doesn't |
thematic:push | who, direction, strength (1), lift (0), flat, mode (add / set), keep, falloff, min_strength | sets or adds velocity |
thematic:teleport | to, gap, distance, spread | moves the wearer onto the target or behind it |
thematic:look_at | who (self / target) | turns the wearer to face the target |
thematic:remove_effect | effect | takes an effect off the wearer; with none, the one an effect_added event brought |
thematic:mark_time | key (required) | stores the world time on the wearer, for a client visual to count from |
thematic:mark_spot | key (required) | fixes a point for the rest of the use, for at: "spot" and away_from_spot |
thematic:extinguish | radius, height (3) | puts out fire in the box around the wearer |
thematic:swap_held_item | cycle, hand, do | swaps 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 to | methods |
|---|---|
| move the wearer | lunge (a throw the way they face), drive (steer every tick), push (set or add velocity, anyone), jump, teleport, carry |
| pick who it lands on | targets("around" | "cone" | "look" | "remembered" | "attacker" | "victim", options?, target => ...), burst (everyone around a point) |
| hurt or change them | damage (the tuned damage; { element: "fire" } for a damage type), ignite (set on fire; { seconds }), effect, knockback, heal, explode, areaDamage |
| leave something on the ground | zone (a patch that keeps hitting whoever stands in it, for the tuned duration), splash (a coloured pool), decal |
| fire something | projectile (a bolt or a throwable, { throwable: "<ns>:<id>" }), beam, spray |
| show and sound it | particles, particleCircle, particleDome, sound, animation |
| remember something for later | markSpot (a point, for { at: "spot" }), store, setFlag, scale (a stat while a phase lasts) |
| time it | wait (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 part | meaning |
|---|---|
lasts | how 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 |
steps | a 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_exit | actions 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_bones | what 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):
| event | when | guards it can ask |
|---|---|---|
hurt | the wearer is about to be hurt, before armour | melee |
damaged | the wearer was hurt | melee, source, element ... |
effect_added | a status effect was just put on the wearer | effect, effect_group |
attack | the wearer lands a melee hit; select: "victim" aims at it | - |
collide | the wearer runs into something solid (phases only) | wall_or_ceiling, touching_for, in_state_for |
release | the key of a hold came up | - |
hit_landed, swing | a hit the wearer dealt; a swing of the arm | source, dealt, hand |
tick | every tick, on a passive ({"every": 0.2, "ready": true}) | - |
press | the 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 plainboolean/number/stringgiven 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 indocs/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.
| power | ability type(s) (a built-in id) |
|---|---|
| Equipment | equip_item, give_item_permanently; a utility belt is inventory_select + inventory_use (utility_belt, utility_use) |
| Flight and gliding | flight_ability (flight), propelled_flight, gliding |
| Leaping | leaping_ability (leaping) |
| Super speed | super_speed_ability (speed_mirage), enhanced_reflexes (speed_force) |
| Healing | healing_factor_ability, regenerative_healing_factor |
| Water breathing, fire immunity | water_breathing_ability, fire_immunity_ability (immunity_fire) |
| Damage resistance and immunity | resistance (per damage type, level one to three), fall_immunity, explosion_immunity, magic_immunity, poison_immunity |
| Effect immunity | potion_immunity (effects listed by the suit), all_status_effect_immunity (immunity_all) |
| Beams | heat_vision_ability, optic_beam, hand_beam, radiation_beam, water_beam |
| Transformation | transformation (toggle a form), banner_hulk_transform (swap to another suit) |
| Blades | toggle_claw, toggle_bone (show and hide model bones) |
| Invisibility, intangibility | invisibility_ability, intangibility_ability |
| Teleporting | teleportation, blink, raiden_teleport |
| Web swinging | web_sling, grapple |
| Wall crawling | climbing, toggle_climbing |
| Size changing | size_change (grow, shrink) |
| Ground smash | ground_pound, hulk_smash |
| Charged punch | punch, flying_punch (powerful_punch) |
| Shields | water_shield, magic_shield, power_shield |
| Projectiles | arrow_catching, projectile_immunity, projectile_reflect (arrow_reflect) |
| Throwables | smokescreen, throwing_knife, grenade |
| Lightning | lightning_throw |
| Telekinesis | psionic_push (push only) |
No ability type covers these yet: metal skin, sentry mode, duplication, spellcasting, sonic waves.
Passive and press abilities#
| type | what it does |
|---|---|
potion_immunity | strips the listed effects (potionEffects) from the wearer |
projectile_immunity | projectile damage is cancelled; beams still hurt |
arrow_catching | with an empty main hand, arrows are caught into the inventory |
teleportation | teleports to the block you look at, within range (default 32, 4..256) |
ability_cooldown | a meter that fills while a move runs and switches that move off when it is full |
transformation | a 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 meansthematic:. - Every built-in throwable is a file under
data/thematic/throwables/: in the main jar the 15 gadgetsgadget_*.json,grenade,tank_grenade,molotovand the thrown weapons and constructs; in the heroes pack jarexploding_teeth,milk_bottle_grenade,flashbang,rubble,powder_gas_bomband 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
/reloadand is synced to clients. Keys starting with_("_comment") are ignored. Unknown keys logunknown 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#
| field | type | default | meaning |
|---|---|---|---|
model | id | the throwable's id | GeckoLib 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_sprite | id | none | draw this item (flat, like a snowball) instead of a model |
name | string | item name, else entity.<ns>.<id> | translation key used in death messages |
render.scale | number | 0.75 | model scale |
render.face_velocity | bool | true | turn the model to face its flight (batarangs); false for round things |
render.animated | bool | false | loop misc.idle from animations/entity/<path>.animation.json; that file must exist |
size | number or [w, h] | 0.3 | hitbox, blocks |
velocity | number | 4.0 | throw speed when thrown as a gadget |
inaccuracy | number | 1.0 | spread when thrown as a gadget |
gravity | number | 0.05 | per tick; 0 = flies straight |
drag | number | 0.99 | speed kept per tick in air |
on_block | stick pass stop bounce | stick (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) | number | 0 | share of speed kept per bounce |
max_bounces | int | 3 | then it sticks |
fuse_ticks | int | 0 (none) | runs on_fuse, then disappears |
explode_on_impact | bool | false | detonate on any hit = explode_on_entity_hit + explode_on_block_hit |
explode_on_entity_hit / explode_on_block_hit | bool | explode_on_impact | detonate on that kind of hit only |
arming_ticks | int | 0 | block hits younger than this don't detonate |
hits_owner | bool | false | can hit whoever threw it |
ignore_owner_vehicle | bool | false | passes through the vehicle its thrower drives |
collide_with_entities | bool | true | false = flies through entities (unless it detonates on them) |
discard_on_entity_hit | bool | true | disappears after a non-detonating entity hit |
impact_origin | hit self_block target_block | hit | where impact actions are centred: exact hit point / its own block / the struck entity's block |
homing | number or {strength, range} | none | turns toward the nearest hittable target; strength 0-1 per tick, range 16 |
trail | {particle, count, interval, spread} | none | particles left in flight (simple particles only) |
land_after_ticks | int | -1 | counts as landed after this long even in the air (gravity off) |
tick_interval | int | 1 | on_tick runs every N ticks |
tick_only_when_landed | bool | false | on_tick counts from landing, and only then |
lifetime_after_landing | int | -1 | gone this many ticks after landing |
damage radius duration amplifier | numbers | 3, 3, 6, 2 | the stats actions read unless they set their own |
cooldown | number | 3 | gadget cooldown, seconds (scaled by the wearer's utility stat) |
item | true or {id, max_count, creative_tab} | none | also 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 actions | bench 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_impact | actions | [] | any hit (after on_entity_hit for a plain hit; before it when detonating) |
on_entity_hit | actions | [] | an entity was hit |
on_block_hit | actions | [] | a block was hit without detonating |
on_fuse | actions | on_impact | the fuse ran out |
on_tick | actions | [] | every tick_interval ticks |
use | actions | [] | 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#
durationis seconds;fuse_ticks/arming_ticks/tick_intervalare ticks.render.animated: truewithout an animation file: the model errors when drawn. Leave it false.modelnames the geo id, not a path:"thematic:gadget_batarang", not"geo/entity/gadget_batarang.geo.json".- A new
itemneeds a game restart (items register at startup) and a lang keyitem.<ns>.<id>plusassets/<ns>/models/item/<id>.jsonwith{"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"withbounce0 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#
| type | fields (default) | does |
|---|---|---|
thematic:damage | damage (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_damage | damage, radius (stats), element (none) | hurts everyone in radius but the thrower and allies |
thematic:effect | effect (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_effect | effect, duration, amplifier, radius | status effect on everyone in radius but the thrower and allies |
thematic:explode | power (2), fire (false), break_blocks (false) | vanilla explosion |
thematic:ignite | seconds (3) | sets the entity hit on fire |
thematic:knockback | strength (0.5) | pushes the entity hit away |
thematic:particles | particle (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:sound | sound (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.
| type | extra 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:smoke | tear_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:bind | bind_equipment (true) | slowness + weakness (amplifier) for duration in radius, bola on the head; stun |
thematic:explosive | power (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:status | effects: [{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#
| type | fields (default) | does |
|---|---|---|
thematic:projectile_damage | damage (stats), element / damage_type (the throwable's) | the gadget hit: damage × thrower's utility attack, typed for weaknesses |
thematic:impact_damage | damage (stats) | arrow-style hit: speed × damage |
thematic:lightning_strike | - | the entity hit is struck by lightning |
thematic:thematic_explosion | damage, 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_area | effects: [{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_circle | system (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_dome | shape (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:heal | amount (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:feed | food (1), saturation (1) | hunger for the user |
thematic:clear_effects | - | removes the user's status effects |
thematic:run_ability_hook | hook (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"meansthematic:blindness; write"minecraft:blindness".particlemust be a simple particle id or aminecraft:dustobject; no block particles.- A stat inside an action (
"radius": 6) is no longer tunable fromprojectile_stats; leave it out to stay tunable. thematic:healandthematic:overshieldamounts are in the action bar's units (1000 is a full vanilla bar,ThematicHelper.healthFromDisplay= value / 50 hit points);damageis a plain hit in hit points. There is nounitsoption 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: lowercasea-z 0-9 _. - Two files with the same
<namespace>:<id>: the first source wins (mod jars, thenthematic/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 changedtypeor identity field (creative_tab,creative_order,max_count,max_damage,rarity,fireproof,food, and each type field marked identity indocs/content-types.md), needs a restart./reloadre-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.jsonand<type>.full.json. Pack example:testpacks/wilfred/data/thematic/items/stage_cane.json.
Common fields (every type)#
| field | type | default | meaning |
|---|---|---|---|
type | id | required | Item type, see below. A bare name means thematic: ("weapon" = "thematic:weapon"). |
creative_tab | id | none (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_order | int | last, then by id | Position 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_count | int 1-64 | 64 | Stack size. |
max_damage | int >= 0 | 0 (no durability) | Durability. Setting it forces a stack of 1; do not also set max_count > 1. |
rarity | string | common | common, uncommon, rare, epic (name colour). |
fireproof | bool | false | Survives lava/fire as a dropped item. |
food | object | none | Makes it edible, see thematic:food. |
_comment, _anything | any | - | 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.
| field | type | default | units | meaning |
|---|---|---|---|---|
model | id | the item's own id | - | Render with another weapon's geo/texture/animation (e.g. "riddler_cane"). |
attack_damage | number | none (no attack attributes) | health points added (full health is about 50) | Main-hand attack damage bonus. Set together with attack_speed. |
attack_speed | number | none | attacks/second modifier | Main-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 field | type | default | units | meaning |
|---|---|---|---|---|
hunger | int | 0 | half-drumsticks | Hunger restored. |
saturation | number | 0.0 | multiplier | Saturation modifier (vanilla bread 0.6). |
always_edible | bool | false | - | Can be eaten when full. |
meat | bool | false | - | Wolves eat it. |
snack | bool | false | - | Eaten fast. |
effects | list | [] | - | 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 itemslasso,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.registerfrom itsthematic_addonentrypoint). 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.jsonand expecting id<ns>:weapons/foo: the id is<ns>:foo. - Numbers as strings (
"amount": "5"): write5. durationin seconds: it is ticks.max_damagewithmax_count> 1: Minecraft rejects it.- Missing
assets/<ns>/models/item/<id>.json: the item renders wrong in hand and GUI even withmodelset;modelonly redirects the GeckoLib geo/texture/animation. attack_damageon a bespoke type: warned as unknown and ignored (onlyweapon/weapon_equippableread it).- Expecting
/reloadto 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 });
});
});
| JSON | script |
|---|---|
| the file | gun(gun => { ... }), or gun("MISC", gun => { ... }). One per file; name (the file name) and type are written for you |
parent, abstract | gun.parent("thematic:example_base_shotgun"), gun.abstract() |
model, texture, modelScale, glowingParts, rotations, ... | gun.model("alfreds_shotgun", { texture: "penguins_shotgun" }) |
hud | gun.hud("thematic:gun_default") |
| the Core table | gun.stats({ ... }) |
Timing and reload (reloadAnimation, idleAnimations, cooldowns) | gun.timing({ ... }) |
| Aiming and HUD | gun.aiming({ ... }) |
| Recoil and handling | gun.recoil({ ... }) |
| Sounds | gun.sounds({ ... }) |
fireModes | gun.fireModes("SINGLE", "AUTOMATIC") |
clips | gun.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 |
effects | gun.effect("HIT_SCAN_ACQUIRED", "tracer") |
features | gun.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) |
phasedReloads | gun.reloadPhase(phase, duration, animation, { condition, shakeEffects }?, phase => { phase.sound(at, name); phase.shake(at, duration); phase.particle(at, name); }) |
| a gun effect | gunEffect("MUZZLE_FLASH", { texture: "...", duration: 80 }) in gun_effects/ |
| a HUD | hud(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._commentkeys are refused in a script. - Rhino, not Node. ES5 plus arrow functions,
let/const, destructuring, template strings, shorthand properties andfor...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.tsdeclares all of it, generated from the table the game installs the API from: typinggun.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'sjsconfig.jsonpoints at it.docs/script-api.jsonsectiongun_scriptis 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.jsbesidex.json, or twoxfiles in different folders of one pack, is an error and neither loads.<model>= the item'smodelfield if set, otherwise<id>.- There is no
pack.jsonand noassetNamespace: the asset namespace is always thedata/<ns>/namespace of the file.
Ids#
| thing | id | how 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.json | mypack: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.json | mypack:y | "mypack:y" |
nameis optional. If present it must equal the file name; otherwise it is ignored and/thematic pack validatewarns.- 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 validatename 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.jsonkey ("tommygun_fire"), or"ns:key". - Display name lang key:
item.<ns>.<id>.
Where files load from (in this order)#
- Every loaded mod jar that has
data/<ns>/gunsordata/<ns>/gun_effects(Thematic's own and pack jars such asthematic-heroes). - Dev environment only:
src/common/resources/data/. <gameDir>/thematic/packs/<entry>/data/for each folder or.zipentry, sorted by name. Entries ending.disabledare skipped.- Pack archives in
<gameDir>/mods/that are not mods (a.zip, or a.jarwith nofabric.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/packsentry are served as a resource pack (client) automatically; nopack.mcmetaneeded. - 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 validateflags 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.
/reloadonly 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.
| old | new |
|---|---|
thematic/gunpacks/<p>/items/x.json | data/<ns>/guns/x.json (<ns> = the old pack.json assetNamespace, usually thematic) |
thematic/gunpacks/<p>/effects/y.json | data/<ns>/gun_effects/y.json |
thematic/gunpacks/<p>/pack.json | delete 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").
| asset | path | required |
|---|---|---|
| geo | assets/<ns>/geo/item/<model>.geo.json | yes; rendering throws "Unable to find model" without it |
| animations | assets/<ns>/animations/item/<model>.animation.json | no; named clips that are missing just don't play |
| texture | assets/<ns>/textures/item/<model>.png | yes |
| item model | assets/<ns>/models/item/<id>.json | yes. 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. |
| lang | assets/<ns>/lang/en_us.json → item.<ns>.<id> | recommended |
| sounds | keys in assets/thematic/sounds.json | for new sounds |
"model": "alfreds_shotgun"makes a new item that draws withalfreds_shotgun's geo and animation file. The new item still needs its ownmodels/item/<id>.jsonand lang entry. Its stats, reload timings and features all come from its own JSON. Animation names you reference (phasedReloads[].animationetc.) 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 followsmodel; with nomodeleither, both follow the file name.- In another namespace, reuse a Thematic gun with
"model": "thematic:alfreds_shotgun".texturetakes a namespace the same way. - There is no separate
animationoverride key: animations followmodel. - 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. Seedata/thematic/guns/cold_gun.jsin the engine jar, which ships no animation file.
JSON tolerance and validation#
- Unknown top-level keys (and unknown keys inside
phasedReloads[]and FireModefireModes[]) don't stop loading, but/thematic pack validateand 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
typeis 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 checkstype,maxAmmoCapacity, effecttexture, 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#
| field | type | default | units | meaning |
|---|---|---|---|---|
type | string | GUN | - | item kind |
name | string | = file name | - | optional; must equal the file name (the id always comes from the file name) |
model | string | = id | - | reuse another gun's geo+animation+texture (see above) |
parent | id | none | - | 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 |
abstract | bool | false | - | a shape to inherit, never registered as an item. See below |
clips | object | none | - | which states this gun animates and the clip for each. See below |
hud | id | none | - | the HUD that draws this gun's counters, e.g. "thematic:example_hud" |
modelScale | number | 1.0 | factor | clamped to 0.1–1.0 |
maxAmmoCapacity | int or "infinite" | REQUIRED | rounds | magazine size. "infinite" = never reloads. Missing → the file fails validation and isn't loaded |
chamberCapacity | int | 0 | rounds | extra rounds on top of the mag ("+1") |
maxAmmoPerReloadIteration | int | unlimited | rounds | rounds loaded per reload cycle. 1 = shell-by-shell |
damage | number | 5.0 | HP points | per hit. Headshot ×3.5. Falls to 0 at 2× maxShootingDistance |
maxShootingDistance | number | 200 | blocks | range. Hip fire capped at 100. Capped at 50 when pelletCount > 1. Also capped by view distance |
rpm | int | 600 | rounds/min | fire rate |
burstShots | int | 3 | shots | shots per BURST pull |
fireModes | string[] | none | - | SINGLE / AUTOMATIC / BURST. Give at least one; with none the gun gets no fire modes and can't fire (validation warns) |
pelletCount | int | 0 | pellets | >1 = shotgun |
pelletSpread | number | 1.0 | cone (unverified unit) | added to inaccuracy when pellets > 0 |
inaccuracy | number | 0.03 | cone (unverified unit) | hip-fire spread |
inaccuracyAiming | number | 0.0 | cone | aimed 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
}
| field | meaning |
|---|---|
clip | the animation name (required in the object form) |
duration | milliseconds; "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 |
loop | true to loop, false to hold the last frame. Omit to keep the state's own: idle loops, every other state holds |
blendIn | whole 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 |
condition | when 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#
| field | type | default | units | meaning |
|---|---|---|---|---|
reloadCooldownTime | int | 1000 | ms | reload length when using reloadAnimation |
reloadAnimation | string | none | anim name | single-clip reload. Used only when phasedReloads is empty |
phasedReloads | object[] | none | - | multi-stage reload, see below. Replaces reloadAnimation |
reloadShakeEffects | object[] | none | - | camera shake during a reloadAnimation reload: {start, duration, ...} like shakeEffects below |
idleAnimations | object[] | none | - | [{name?="animation.model.idle", duration REQUIRED ms, condition?}] |
fireAnimations | object[] | none | - | [{name?="animation.model.fire", condition?}] (no duration) |
prepareFireCooldownDuration | int | 0 | ms | wind-up before the first shot |
completeFireCooldownDuration | int | 0 | ms | wind-down after firing stops |
enableFireModeCooldownDuration | int | 0 | ms | delay when switching fire mode |
prepareIdleCooldownDuration | int | 0 | ms | delay before idle |
minTargetLockTime | int | 0 | ms | lock-on time (converted to ticks) |
phasedReloads[] entry:
| field | type | default | meaning |
|---|---|---|---|
phase | string | REQUIRED | PREPARING | RELOADING | COMPLETING. Exact upper case |
duration | int | REQUIRED | ms |
animation | string | REQUIRED | clip name in the animation file |
condition | condition | always | picks between entries of the same phase (see Conditions) |
shakeEffects | object[] | none | [{start REQUIRED ms, duration REQUIRED ms, initialAmplitude=0.15, rateOfAmplitudeDecay=0.3, initialAngularFrequency=1.0, rateOfFrequencyIncrease=0.01}] |
Aiming and HUD#
| field | type | default | meaning |
|---|---|---|---|
aimingEnabled | bool | true | allow aiming. Also creates the default Aiming feature |
aimingZoom | number | 0.05 | zoom of the default Aiming feature (0.25 = 25%). Ignored if an explicit Aiming feature is given |
aimingCurveX/Y/Z | number | 0.0 / -0.07 / 0.3 | aim-down-sights position curve |
aimingCurvePitch/Yaw/Roll | number | -0.01 each | aim rotation curve |
scopeOverlay | string | none | full texture path, e.g. "thematic:textures/gui/x.png" (no namespace → thematic); a flat texture that covers the screen while aiming |
targetLockOverlay | string | none | texture path |
targetLockedSound, targetStartLockingSound | string | none | sound keys |
Recoil and handling (camera feel; unitless unless noted)#
| field | type | default | meaning |
|---|---|---|---|
viewRecoilAmplitude | number | 1.0 | camera kick size |
viewRecoilMaxPitch | int | 20 | max accumulated camera pitch (degrees, unverified) |
viewRecoilDuration | int | 100 | ms |
shakeRecoilAmplitude | number | 0.5 | screen shake size |
shakeRecoilSpeed | number | 8.0 | screen shake speed |
shakeDecay | number | 0.98 | shake falloff per step |
shakeRecoilDuration | int | 400 | ms |
gunRecoilInitialAmplitude | number | 0.3 | model kick size |
gunRecoilRateOfAmplitudeDecay | number | 0.8 | |
gunRecoilInitialAngularFrequency | number | 1.0 | |
gunRecoilRateOfFrequencyIncrease | number | 0.05 | |
gunRecoilPitchMultiplier | number | 1.0 | |
gunRecoilDuration | int | 500 | ms |
gunRandomizationAmplitude | number | 0.01 | idle sway |
idleRandomizationDuration | int | 2500 | ms |
recoilRandomizationDuration | int | 250 | ms |
shotsPerRecoil | int | 1 | play recoil every N shots |
jumpMultiplier | number | 1.0 | jump bob scale |
bobbing | number | 1.0 | walk bob. Clamped 0–2 |
bobbingOnAim | number | 0.3 | walk bob while aiming. Clamped 0–2 |
bobbingRollMultiplier | number | 1.0 | clamped 0–10 |
sustainedRecoil | bool or object | off | climb 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, sustainedRecoilDecayTime | flat legacy spellings | - | same settings. Read after the object, so they win. Any increment key switches sustained recoil on |
Sounds#
| field | type | default | meaning |
|---|---|---|---|
fireSound | string | none (silent) | sound key |
fireSoundVolume | number | 5.0 | |
headshotSound | string | built-in | sound key |
headshotSoundVolume | number | 3.0 | |
reloadSound | string | - | 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#
| field | type | default | meaning |
|---|---|---|---|
effects | object[] | 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 |
features | object[] | none | see Features. An explicit feature replaces the default one of the same type |
glowingParts | object[] | 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 |
rotations | object[] | none | spinning 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")#
| field | type | default | meaning |
|---|---|---|---|
name | string | = file name | optional; must equal the file name |
renderer | object | none | {"type":"model"} → geo renderer on <ns>:geo/item/<id>.geo.json |
Features (features: [{ "type": ..., ... }])#
- Every feature except
PartVisibilityaccepts an optionalcondition(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 FIRINGMUZZLE_FLASHeffects), Reload, Aiming (ifaimingEnabled).
| type | fields | meaning |
|---|---|---|
Accuracy | accuracyModifier number REQUIRED | spread divided by this (>1 = tighter) |
Aiming | zoom=0.1, viewBobbing=1.0 | aim zoom (0.25 = 25%) |
Damage | hitScanDamageModifier number REQUIRED | multiplier on hit-scan damage |
FireMode | fireModes: object[] (below) | replaces the default fire modes. Each mode still inherits the gun's top-level stats it doesn't set |
AmmoCapacity | ammoCapacityModifier int (multiplier) OR ammoCapacity int (absolute) | magazine change. The modifier is an integer, so ×1.5 is impossible |
Reload | maxAmmoPerReloadIteration int | overrides the top-level value |
MuzzleFlash | effects: [{phase, name, condition?}] | muzzle flash effects. Replaces those derived from top-level effects |
PartVisibility | parts: [{name bone, visible=true, condition}] | show the bone when the condition holds (hide it when visible:false). No feature-level condition |
Recoil | recoilModifier number REQUIRED | recoil multiplier |
Sound | fireSounds: [{sound REQUIRED, volume=5.0, condition?}], OR fireSound REQUIRED + fireSoundVolume=5.0 | replaces the fire sound (e.g. when suppressed) |
Skin | texture REQUIRED | alternate gun texture |
Beam | coreWidth=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) |
ActiveMuzzle | parts: [{name bone, condition?}] | which muzzle bone effects spawn from |
DualWield | twin? (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:
| field | type | default | meaning |
|---|---|---|---|
name | string | REQUIRED | mode id, used by the selectedFireMode condition |
displayName | string | = name | lang key / text |
type | string | SINGLE | SINGLE | AUTOMATIC | BURST |
rpm | int | gun rpm, else 600 | |
burstShots | int | gun burstShots, else 3 | |
maxAmmoCapacity | int | gun maxAmmoCapacity, else 1 | magazine size for this mode |
damage | number | gun damage, else 5.0 | HP points per hit |
maxShootingDistance | int | gun maxShootingDistance, else 200 | blocks |
pelletCount / pelletSpread | int / number | gun values, else 0 / 1.0 | |
isUsingDefaultMuzzle | bool | true | |
shakeRecoilAmplitude/shakeRecoilSpeed/shakeRecoilDuration | number/number/int ms | 0.5 / 8.0 / 400 | per-mode view shake; applies only if at least one is set |
animationName | string | animation.model.fire | fire 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":{...}}.anyOfitems must be objects;allOfitems may be strings. - Fire mode:
selectedFireMode/unselectedFireMode(fire-modename). - 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 varammoCount|reloadIterationIndexand 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 (
yforthematic,<ns>:yotherwise; 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,MuzzleFlashfeatureeffects[].name, and fire-modeeffects[].name. - Resolution is lazy, so an effect from another pack works if that pack loads.
| field | type | default | meaning |
|---|---|---|---|
name | string | = file name | optional; must equal the file name. Clashing with a built-in effect name → this file fails to load |
type | string | REQUIRED | MUZZLE_FLASH | FIRE_MUZZLE_FLASH | IMPACT | TRAIL | ATTACHED_PROJECTILE | DETACHED_PROJECTILE | GLOW_BEAM |
texture | string | REQUIRED | full path, e.g. "thematic:textures/effect/muzzle_yellow.png" (no namespace → thematic) |
duration | int | 1000 | ms |
delay | int | 0 | ms (unverified unit) |
blendMode | string | NORMAL | NORMAL | ADDITIVE | NONE |
depthTest | bool | true | |
glow | bool | false | full-bright |
brightness | int | 1 | |
numRotations | number | 0 | spins over the lifetime |
initialRoll | number | random 0–360 | degrees |
color | int | 16777215 (0xFFFFFF) | decimal int only. A "#hex" string fails, unlike Beam |
alpha, width | number or interpolator | 1.0 | interpolator = {"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} |
sprites | object | none | sprite sheet {rows=1, columns=1, fps=60, type=LOOP (STATIC|RANDOM|LOOP|PLAY_ONCE)} |
Type-specific fields:
| effect type | valid phases | extra fields |
|---|---|---|
MUZZLE_FLASH | FIRING | - |
FIRE_MUZZLE_FLASH | FIRING | - (thematic:muzzle_fire is one) |
IMPACT | HIT_TARGET | count (a number, or {"type":"DAMAGE_BASED","maxCount","maxDamage"}), impactWidth (a number, or {"maxHitSize","maxWidth"}) |
TRAIL | FLYING (projectile effects) | longitudeOffset=0 |
ATTACHED_PROJECTILE, DETACHED_PROJECTILE | PREPARING, FIRING, HIT_SCAN_ACQUIRED, COMPLETETING | face{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_BEAM | the beam's phases | coreWidth, 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.jsonassets/mypack/textures/item/my_pistol.pngassets/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.jsonwithanimation.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 agunsand agun_effectsline (N loaded, M failed), every failing file with its id, path and the field at fault, and yellow warnings for unknown fields, anamethat doesn't match the file, duplicate ids across packs and oldthematic/gunpacksfolders. Details are also inlogs/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 defined | a 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 todata/<ns>/guns/(see "Moving from the old layout") and deletepack.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
namefield can't override it. - Missing namespace in references. Items and effects outside
thematicare referred to as"<ns>:<name>"everywhere (effects[].name, ...). A bare name only findsthematiccontent. - Required fields. A GUN needs
maxAmmoCapacity; an effect needstypeandtexture. - Case-sensitive values.
phasedReloads[].phasemust be exactly upper case ("RELOADING", not"reloading").rotations[].phasemust be exactly"fire"or"reload".
modelreuse.- Expecting
modelto cover the item model: it doesn't. You still needassets/<ns>/models/item/<id>.jsonand a lang entry. - Animation names in
phasedReloadsetc. must exist in the reused gun's animation file.
- Expecting
- Asset paths.
- Assets in the wrong namespace: everything (geo, animation, texture, item model, lang) goes under the same
<ns>asdata/<ns>/guns/. Only new sound ids go inassets/thematic/sounds.json. - Short texture names don't resolve: texture fields (effects, overlays, glow, skin) need the full path with
textures/and.png.
- Assets in the wrong namespace: everything (geo, animation, texture, item model, lang) goes under the same
- Effects.
- Missing
phasein a guneffectsentry: 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
coloras"#RRGGBB": fails. Use a decimal int. (BeamcoreColor/glowColordo accept hex strings.)
- Missing
- Unused keys.
reloadSound,animationType,hitScanSpeed,shotsPerTracedo nothing. Don't tune them. - Restart and sync. Editing a gun and expecting
/reloadto 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":
| value | meaning |
|---|---|
"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#
| field | default | meaning |
|---|---|---|
speed | 0.175 | hover speed. 0.175 is Thematic's normal and the ratio to it scales the fly speed (clamped ×0.4…×2.5). |
enabled | true | false for a flyer who cannot hover at all — no creative flight, only fast flight |
settleTicks | 5 | ticks of hovering before the hover clip takes over from a take-off or an exit |
fast — fall-flying, the sprint-into-the-sky half#
| field | default | meaning |
|---|---|---|
speed | 0.375 | total fast-flight speed, in the same unit; clamped ×0.4…×3 |
canBoost | true | false = hover only, no fast flight at all |
sprintToEnter | true | whether sprinting in mid-air takes off on its own |
airborneTicks | 20 | ticks airborne first. A sprint jump is airborne for about twelve, so much under twenty catches the top of an ordinary jump |
entrySpeed | 1.0 | blocks per tick of horizontal speed at which a hovering flyer is handed over to fast flight |
acceleration | 0.4 | how hard the velocity is pulled toward the target each tick |
maxSpeed | 1.5 | the target's length, before the speed multiplier |
responsiveness | 0.1 | how much of the look direction is added outright each tick — what makes a turn bite rather than arc |
clampMin, clampMax | 0.5, 14.0 | floor and ceiling on the suit's own speed stat's contribution |
graceTicks | 2 | ticks the pose outlives the fall-flying flag for players you are only watching, so one blanked sync doesn't restart every clip |
hoverRestoreTicks | 20 | ticks the hand-off back to hover keeps re-asserting itself after fast flight ends |
turnRate | 0 | degrees 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.
| field | default | meaning |
|---|---|---|
maxTilt | 18.0 | degrees of steering roll and pitch a strafe reaches |
springIn | 0.3 | how quickly the lean deepens (about 16 ticks from a standing start) |
springOut | 0.42 | how quickly it eases back (about 12) |
startSpeed | 0.45 | blocks per tick at which the hover lean begins. Must be below fullSpeed |
fullSpeed | 1.3 | blocks per tick at which it reaches its cap |
hoverCap | 0.4 | how far over hovering alone may carry the body; fast flight takes it the rest of the way |
backwardLean | 18.0 | degrees of lean back when hovering backward |
travelFollow | 0.3 | per-tick follow rate of the heading toward where the flyer actually moved |
stepTicks | 0 | 0 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) |
snapDegrees | 0 | above 0, the fast-flight pitch and bank are rounded to multiples of this many degrees, so the body clicks between attitudes |
fastPitchBias | 0 | degrees 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#
| field | default | meaning |
|---|---|---|
intro | super.flight.nohand | played once when fast flight starts, then held on its last frame |
loop | super.flight.nohand.loop | takes over from the intro, or is the whole of fast flight when there is no intro |
toHover | super.flight.nohand.exit | played when fast flight ends in mid-air |
toRest | super.flight.nohand.exit | played when it ends on the ground |
hoverIdle | super.flight.idle | the hovering float |
glide | glide.flight.idle | fall-flying without the flight ability |
blendTicks | 3 | ticks blended between them |
style | default | propelled 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 |
legSpread | 0 | propelled only: degrees each leg splays out from the other. 0 is feet together under the hips |
legTilt | 16 | propelled 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 |
bob | 2.5 | propelled only: degrees of gentle leg bob. 0 for a rigid machine |
armBack | 14 | propelled only: degrees the arms sweep back at full speed (positive is back) |
armOut | 2 | propelled only: degrees the arms stand out from the body while hovering |
armHover | 0 | propelled only: arm pitch at rest in degrees, before speed sweeps them to armBack (negative is forward) |
armTwist | 0 | propelled 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#
| field | default | meaning |
|---|---|---|
speedLineLean | 0.55 | lean below which no speed lines appear. A lean and not a speed: a cruising hover leans a little, a committed fast flight leans fully |
speedLineSpeed | 1.4 | blocks per tick at which the line rate tops out |
speedLineRate | 0 | expected 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 |
boomTicks | 100 | ticks of unbroken fast flight before the sonic boom. 0 for a flyer who never booms |
boomSound | sonic_punch | the Thematic sound played at that moment |
jets | [] | emitters, each firing when its when says (below) |
antiGrav | none | an 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:
when | fires |
|---|---|
always | whenever the flyer is airborne under their own power: hovering or fast (the old "hover": true) |
hover | only while hovering |
fast | only in fast flight (the default for a jet that says neither when nor hover) |
dash | only 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 field | default | meaning |
|---|---|---|
color | #FF1F3F | #RRGGBB of the pad, the ripple and the streak |
size | 0.3 | blocks, radius of the pad under each foot |
pulse | 0.4 | 0 to 1: how deeply the pad's brightness breathes; 0 is steady |
period | 40 | ticks one breath (and one ripple) takes |
shimmer | 0.5 | 0 to 1: brightness of the ripple spreading under a hovering flyer, fading out as they speed up; 0 leaves it out |
streak | 0.6 | blocks 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 field | default | meaning |
|---|---|---|
core | #FFF3B0 | colour of the inner cone |
outer | #FF4A1C | colour of the outer cone and the nozzle glow |
length | 0.9 | blocks long at rest (up to 2.6 times that at full speed) |
width | 0.12 | blocks of radius at the nozzle |
flicker | 0.25 | 0 to 0.9: how much the length and width jitter each frame |
glow | 1 | 0 to 1: brightness of the short, wide heat glow round the nozzle; 0 leaves only the two cones |
hover | 1 | 0 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.
| field | default | meaning |
|---|---|---|
clip | superhero_landing | the animation clip's name |
minFallSpeed | 0.6 | blocks 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 |
fromFlight | true | whether a descent out of flight counts; false is for plain falls only |
dust | 16 | particles in the puff thrown up at the feet, 0 for none |
sound | minecraft:block.anvil.land | the 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" }
]
}
| field | applies to | meaning |
|---|---|---|
type | all | TEXT, TEXTURE or BAR (required) |
anchor | all | which corner, edge or centre it hangs from: TOP_LEFT … BOTTOM_RIGHT, CENTER. Default TOP_LEFT |
x, y | all | pixels from the anchor, applied after it — so a negative x on a right anchor insets from the right edge |
when | all | always (default), holdingGun, aiming, notAiming, reloading, firing, hasAmmo, outOfAmmo |
color | all | "#RRGGBB" or "#AARRGGBB". Six digits is opaque |
text | TEXT | the line, with {ammo}, {capacity}, {fireMode}, {gunName}, {reloadProgress} placeholders. An unknown one fails validation |
scale | TEXT | 1 is the vanilla font |
shadow | TEXT | drop shadow; true when missing |
texture | TEXTURE | the image id |
fit | TEXTURE | FIXED (default, needs width/height) or COVER to fill the screen |
width, height | TEXTURE, BAR | pixels |
value | BAR | what fills it, 0 to 1. Today: reloadProgress |
background | BAR | ARGB 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 }
]
}
type | fields |
|---|---|
SOUND | name (required), volume, pitch |
SHAKE | duration (required), initialAmplitude, rateOfAmplitudeDecay, initialAngularFrequency, rateOfFrequencyIncrease |
PARTICLE | name (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 asthematic:spideyfirst, 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:
/reloadpicks up edits, and clients receive them from the server.
Fields (all optional)#
| field | type | meaning |
|---|---|---|
includes | list of ids | other tags (or suit ids) whose payload comes first. This file's own entries then add to or replace theirs. |
abilities | list 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). |
stats | list of {"identifier", "minimum", "maximum"} | as in a suit |
fighting_type | list of strings | as in a suit; a non-empty list replaces an included one |
attributes | list of {"identifier", "effect"} | Pehkui scales, as in a suit (pehkui:jump_height 1.7 = 70% higher jumps; pehkui:falling -1 = no fall damage) |
cape, glider | object | accepted 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
tagsis silently a plain flag./thematic pack validatelists tags that loop orincludesnothing;./gradlew validateContent(source repo) checks that tag abilities exist. - Expecting a tag's
capeto show: the suit's owncapefield 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)#
| field | type | default | meaning |
|---|---|---|---|
entity | id | none | the 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_scale | number | 1.0 | multiplier 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_height | number | 1.0 | Pehkui 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). |
motion | number | 1.0 | Pehkui motion factor. 1.35 is "quicker than a man". |
flight | true/false | false | creative-style flight while worn, taken back on the way out |
hands_free | true/false | true | whether the shape can still swing and use things. A wolf can; a cloud cannot. |
right_click | ability id | none | the 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_entity | true/false | false | with 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_water | true/false | false | the shape ignores drowning |
immune | true/false | false | the 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_through | list of block ids and #block tags | none | blocks 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"] |
nbt | object (or SNBT text) | none | entity data read into the drawn body, as /summon would ({"CollarColor": 14}). Cosmetic: only the client reads it. |
looks | list of {"flag", "nbt", "render_scale"} | none | how 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. |
traits | list of {"effect", "amplifier"} | none | status 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. |
abilities | list of ability ids | none | the 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"} | none | the noise of taking the shape and of giving it back. change/ambient are the older spelling of the same two. |
particle | id | none | the 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
hitboxas 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 ownabilitiesare 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
hitboxa long way and leavingeye_heightat 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'svisual-- a name,{"id": name, ...options}or a list. The heroes pack'sdracula/bat_form.jsongives the bat a redthematic:vision(every living thing outlined through walls) and a faint redthematic: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, itshealrow).
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
}
| field | type | default | meaning |
|---|---|---|---|
recipe_type | id | required | the machine: thematic:alloy_smelting (2 input slots, fuelled, 200 ticks), thematic:centrifuging (3 slots, 200) or thematic:plate_press (1 slot, 55) |
inputs | list | required | one 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 |
result | object | required | {"item", "count"} or {"id", "Count"} |
experience | number | 0 | |
time | ticks | the 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.
| field | type | default | meaning |
|---|---|---|---|
dimension | id | required | the 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} | none | a corridor along Z rebuilt on every entry (length 512, radius 6 by default) |
resident | {entity, name, distance, delay, floor, marker} | none | someone waiting on a platform distance blocks down the corridor, made delay ticks after entry |
cells | {shells, core, ...} | none | hollow glass atoms on a grid (membranes, giant, orbitals, nucleon, electron, seed...) |
gravity | number | 1 | the pocket's pull as a fraction of the overworld's; applied by the wearer's client |
look | {shader, pulse, motes, fog, near} | none | how 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 } ] } ]
}
| Field | Meaning |
|---|---|
tables | loot table ids; * matches any run of characters (minecraft:chests/*) |
exclude | optional, same syntax; wins over tables |
pools | ordinary 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.