Skip to content

Maps, opponents, fights, and rewards

A zone is a map page. A battle is a selectable entry on that page. A warrior describes an opponent. A fight connects a battle to opponents, rules, round settings, and rewards. Create these in that order so references exist before you use them. See Your first battle for a complete connected example.

All registration and lookup functions here use content.register and run during the entrypoint. Cross-mod content requires the corresponding dependency. Tables reject unknown fields. Examples assume local sf2 = require("sf2").

Some presentation fields in this part of the API still take strings, such as "my.mod:localization/battle.title", rather than localization handles. Respect the field type: a handle cannot be substituted for a string.

Create a map page for your battles.

Signature: sf2.zones.register { id, file?, start?, underworld? }

Requires: content.register.

When: Entrypoint, before its battles.

Returns: A zone handle.

id is a required local ID. file is a string naming existing map art, default ""; start is a boolean defaulting to false. Reuse a known map file when starting out; a zone ID is not the map-art filename.

The default story backgrounds are "Map1.1", "Map1.2", "Map1.3", "Map2.4", "Map2.5", "Map2.6", and "Map3.7", in act order. They use Eclipse’s packaged 2040 by 972 overworld artwork, so a mod zone reusing one of these names gets the same background as the base game. The legacy first-act name "Map0.1" and the standalone seventh-act name "7" also work in the map UI.

underworld is a boolean defaulting to false. true places the page on the Underworld map (the raid map the player reaches with the map’s Underworld toggle) instead of the story map, next to the game’s own raid pages. An Underworld page cannot also be the start zone. Raid map art such as "Raid1.1" fits these pages. See Underworld pages for the complete flow.

local zone = sf2.zones.register { id = "training", file = "Map1.1", start = false }
local depths = sf2.zones.register { id = "depths", file = "Raid1.1", underworld = true }

Obtain an existing zone’s handle instead of creating a new page.

Signature: sf2.zones.get(reference)

Requires: content.register and a dependency on an external owner.

When: Entrypoint.

Returns: A zone handle; a missing or inaccessible ID raises an error.

-- After the registration above, retrieve this mod's zone by local ID:
local same_zone = sf2.zones.get("training")

For another owner, use its complete owner:zones/id definition ID.

Create the map entry that the player selects to open a fight.

Signature: sf2.battles.register(definition)

Requires: content.register.

When: Entrypoint, after the zone.

Returns: A battle handle.

Field Type Default Meaning
id String Required Local battle ID.
zone Zone handle Required Owning map page.
type Battle constant/string Required Game battle category; start with sf2.battles.STORY.
x, y Integers 0 Placement on the map page.
alias, title, description Localization handle or string "" Text shown on the map: a handle from sf2.localization.key, or literal text.
icon, icon_atlas Strings "" Existing map-button art identifiers.
preview Sprite handle or string "" Battle-panel preview image: a sprite handle for art your mod ships, or an existing native preview name such as preview_main.statue.
eclipse_toggle_name String "" Runtime name of this battle’s Eclipse-mode counterpart in the same zone; see below. Ignored on "raid" entries.
location Location handle or ID The dojo Arena: a location your mod registered, or a core ID such as "core:locations/dojo" from the Locations catalog.
music Audio handle or string "" Fight music: a handle from sf2.assets.audio for a track your mod ships, or a native track name such as raids_vortex.
reward_image String "" Reward presentation image reference.
show_resistance Boolean false Whether to show resistance presentation.
power_mode "normal" or "power" Always shown Underworld pages only: show the entry only while Power Mode is off ("normal") or on ("power").
icons Table of sprite handles Native button art Your own map-button sprites; see Map-button sprites.
local battle = sf2.battles.register {
id = "training_battle", zone = zone, type = sf2.battles.STORY,
title = "my.mod:localization/battle.title", x = 0, y = 0,
-- Requires assets/sprites/training_preview.asset and its PNG texture.
preview = sf2.assets.sprite("sprites/training_preview"),
}

The map’s battle panel shows preview when the player selects the entry. Pass a handle from sf2.assets.sprite to show your own image; a missing or wrong-kind asset fails registration. Core battle previews are 484 × 274 pixels, so match that size to fill the panel the same way. A plain string is kept for compatibility and is looked up in the game’s native UI/battles/ folder only; it is not checked when you register the battle, so a misspelled name shows an empty panel instead of raising an error. icon and icon_atlas select existing map-button art; use icons for your own.

icons replaces the round map button with sprites your mod ships. It is a table with these sprite-handle fields (from sf2.assets.sprite):

Field Default Meaning
base Required Button while unlocked and not selected.
active None Button while selected. Supply it: without it the selected state has no mod art.
locked Native lock art Button while the battle is locked.
locked_active None Selected button while locked; used together with locked.

Strings are rejected, and a missing or non-sprite asset fails registration. Core map buttons are 300 × 300 pixels. Leave icon_atlas empty when you set icons.

local boss = sf2.battles.register {
id = "gatekeeper", zone = depths, type = sf2.battles.FINAL, x = 400, y = 900,
-- Requires assets/sprites/buttons/gatekeeper_base.asset and _active.asset.
icons = {
base = sf2.assets.sprite("sprites/buttons/gatekeeper_base"),
active = sf2.assets.sprite("sprites/buttons/gatekeeper_active"),
},
}

The Underworld map has a Power Mode checkbox. By default an entry on an Underworld page is shown in both states. power_mode = "normal" shows it only while Power Mode is off; "power" only while it is on. Pair a normal boss with a harder variant at the same position to make the checkbox swap them. The field is rejected on story pages; omit it for entries that should always be visible.

local normal = sf2.battles.register { id = "boss_1", zone = depths, type = sf2.battles.FINAL, power_mode = "normal" }
local power = sf2.battles.register { id = "boss_1_power", zone = depths, type = sf2.battles.FINAL, power_mode = "power" }

Constants on sf2.battles are DUMMY, TUTORIAL, CHALLENGE, BOSSES, TOURNAMENT, STORY, SURVIVAL, FRIENDLY, AUTO, AI, HIDDEN, FAKE, PVP, FINAL, and FINAL_TITAN. The string "raid" is also accepted for registered offline raids. A category constant does not implement an online service or arbitrary game mode; use the supported offline mode APIs.

Create a fight for the battle and reveal it through a quest or sf2.battles.reveal. Merely registering an entry does not guarantee that an existing story save will display it.

To pair a normal battle with an Eclipse variant, register both in the same zone. Set only the normal entry’s eclipse_toggle_name to the variant’s full runtime name: for an owned battle with local ID training_eclipse, that is sf2.mod.id .. ":battles/training_eclipse". This string is not a localization key. Give each entry its own fights; the field does not create the variant or copy opponents and rewards. The game links the pair by name within that zone when the zone is first used; a missing or misspelled target is logged and never switches. Several normal entries may name the same variant.

The game’s Eclipse battle update (the UpdateEclipseBattles quest action, also available as update_eclipse_battles) then exchanges the pair:

  • Nothing happens until the normal entry’s last fight has been won at least once. The normal entry’s lock is not checked.
  • In Eclipse mode the normal entry is hidden and the variant shown; in normal mode the reverse. The variant is added to the save when it is first needed (unlocked, with its replay count set from the wins of its own last fight).
  • Switching modes only swaps pairs whose variant is already saved. After a fight, only missing variants in that fight’s zone are added.
  • The vanilla Eclipse quests run these updates only after the player has finished the Eclipse replay tutorial (or won the Act VI final battle). Before that, an unfinished pair keeps its normal entry and Eclipse mode applies the normal battle’s Eclipse-only rules instead.

These rules apply at those update points, not to custom encounter-launch code.

Move an existing battle’s button on its map page.

Signature: sf2.battles.patch { target, x?, y? }

Returns: nil.

When: Entrypoint, after the target’s owner has committed its definitions. Core battles are always committed first.

Requires: content.patch and a dependency on the target’s owner (for example core).

Field Type Default Meaning
target String Required Qualified battle ID, such as core:battles/zone_6/duel.
x Integer, −10,000–10,000 Keep current New horizontal map position.
y Integer, −10,000–10,000 Keep current New vertical map position.

Supply x, y or both. The coordinates use the same map-page space as sf2.battles.register: positive x is right, positive y is up. Only the placement changes. The battle keeps its identity, type, fights, art, lock state and saved progress, so existing saves are unaffected.

Core battle IDs follow core:battles/<zone>/<battle>, with the zone and battle names from stages.xml in lower case. Each Eclipse-mode or intermission twin is a separate battle; move it too if it shares the spot, as with Act 6’s Duel below.

local sf2 = require("sf2")
-- Declare content.patch and a dependency on core in mod.toml.
-- Act 6's Duel normally sits at (-370, 50); move it and its intermission twin.
sf2.battles.patch { target = "core:battles/zone_6/duel", x = -300, y = 100 }
sf2.battles.patch { target = "core:battles/zone_6/duel_intermission", x = -300, y = 100 }

Limits and errors:

  • Place your own battles with x and y in sf2.battles.register. Patching a battle registered by your own mod is an error.
  • One mod may patch a battle’s placement once. A second mod that patches the same battle is rejected as a conflict, and its registration rolls back.
  • Unknown targets, non-battle IDs, missing x and y, non-integer or out-of-range coordinates and any other field raise an error.
  • The placement is part of the saved content fingerprint. Disabling the mod and restarting restores the original position.

Registration, conflicts, rollback and fingerprinting have automated tests. The native map applies the new position when the game loads its stages; this has been compiled but not yet checked on the in-game map.

Change the saved lock of a revealed battle owned by this mod.

Signature: sf2.battles.set_locked(battle, locked)

Returns: true when applied or already in the requested state. false when the profile/map is unavailable, native input is blocked, a transition or encounter preparation is pending, or the battle has no saved revealed entry.

When: On an initialized map, such as a normal UI on_click callback after story presentation. Calls during UI on_close cleanup raise an error; cleanup may run because the scene/profile is being replaced. Do not retry in a tight loop.

Requires: story.progression, this script’s own battle handle and a boolean locked. Strings, forged handles, core battles and other mods’ battles are not accepted. Register entries first, then reveal them before changing locks.

-- battle was registered by this mod. In a normal map UI button callback:
if sf2.battles.set_locked(battle, false) then
sf2.ui.close(view) -- also requires ui.create
end

This changes only the native saved lock flag, refreshes map presentation and requests a normal profile save. It does not reveal a missing entry, change hidden state, reset replay counts or clear fight history. Unlocking does not override other native fight conditions. Repeating the same value is harmless. A return value is not a guarantee that a disk write has completed. Use declared mod state for your own notification flags, and mark them only after accepting the change.

Create the saved map entry for a battle owned by this mod, then refresh the map.

Signature: sf2.battles.reveal(battle, locked)

Returns: true when the entry exists or was created; false when the profile, map or native battle is unavailable, input is blocked, or a transition, encounter preparation or settlement prevents progression.

When: On an initialized map, usually from an ordinary UI on_click after story presentation. UI on_close cleanup calls raise an error. Do not retry in a tight loop. Several reveals may be followed by a focus in the same callback.

Requires: story.progression, this script’s own registered battle handle, and a strict boolean locked. Strings, foreign/core handles and forged handles are rejected. Register the battle’s fights during loading.

-- Inside a normal map callback, using a battle registered during loading:
if sf2.battles.reveal(battle, true) then
-- Later progression can call sf2.battles.set_locked(battle, false).
end

locked is the initial value for a new entry only. Repeating a reveal preserves an existing lock, hidden flag, replay count and fight history. It can restore the entry’s native visibility, but never overrides an existing hidden flag or the current story/raid map mode. A new entry is not hidden and starts at replay count zero. Reveal neither starts a fight nor bypasses its conditions. The host requests a normal save when it changes the entry; success does not guarantee completed disk I/O. This operation is idempotent, not an atomic transaction across several battles.

Select an owned battle already represented on the current map.

Signature: sf2.battles.focus(battle)

Returns: true when focus is applied; false when the entry is absent, hidden, outside the current map mode, or blocked by the same lifecycle/input guards as reveal.

When: On an initialized map, including after reveal in the same UI callback. Calls from UI on_close cleanup raise an error.

Requires: story.progression and this script’s own battle handle.

if sf2.battles.set_locked(battle, false) then
if sf2.battles.focus(battle) then
-- The caller can now record its own notification-completed flag.
end
end

Focus uses immediate native map selection, updates the relevant story/raid saved focus, and requests a normal save. Locked visible entries may be focused. It does not reveal entries, change lock/hidden state, switch map modes, or start combat. Native selection can subsequently choose a fight within that battle. The API does not guarantee that the save is already durable on disk.

Get an existing opponent template as the starting point for a new warrior.

Signature: sf2.warriors.get_template(reference)

Requires: content.register and the template owner’s dependency.

When: Entrypoint.

Returns: A warrior-template handle; missing IDs raise errors.

local template = sf2.warriors.get_template("core:warrior-templates/default")

Template handles differ from warrior handles; pass this one to the template field of sf2.warriors.register or sf2.warriors.register_template.

A template lookup requires a definition already present in the catalog. Naming a missing template does not restore its skeleton, inherited equipment, voice or other native settings. For reusable opponent modules, pass verified template and item handles into a registration function and check its required inputs before registering variants. Leave incomplete modules outside the entrypoint until their dependencies are available. A successful comparison of projected loadout fields does not establish that the inherited template or equipment works in combat.

Define your own opponent template: shared settings that several warriors inherit.

Signature: sf2.warriors.register_template(definition)

Requires: content.register.

When: Entrypoint, before the warriors that use it.

Returns: A warrior-template handle, accepted wherever sf2.warriors.get_template handles are.

The table takes the same fields as sf2.warriors.register except group, random, body_model and skin_models: id, template, first_name, last_name, avatar, voice, level, tactic, attributes, attribute_alignments, items, perks, health_bars, visible_health_bars and skeleton. template names a parent template (core or one of yours registered earlier), so templates can form a chain.

local default = sf2.warriors.get_template("core:warrior-templates/default")
local boss = sf2.warriors.register_template {
id = "fire_boss", template = default,
first_name = "my.mod:localization/fire_boss.name", voice = "Male",
health_bars = 15, visible_health_bars = 15,
items = { sf2.items.get("core:items/weapon/WEAPON_KATANA") },
skeleton = "SkeletonHeavy",
}
local fighter = sf2.warriors.register { id = "fire_boss_1", template = boss, level = 40 }

The game merges inheritance field by field, with one exception: a template’s item list replaces its parent’s list instead of adding to it. A template that sets items must therefore list everything its warriors wear, including the body; set skeleton as well so the body is not lost. Warriors that set their own items replace the template’s list in the same way. Templates are part of the saved content fingerprint. attribute_alignments rows are additive: a child template or warrior retains inherited rows and appends only the rows it declares. For example, a parent with seven rows and a warrior with two rows gives that warrior nine rows. Omit the field to inherit the parent’s rows as they are.

Define a character, optionally inheriting from a core template. Use its handle for an opponent, an independent actor or an owned fight’s player_character.

Signature: sf2.warriors.register(definition)

Requires: content.register.

When: Entrypoint, before registering the fight.

Returns: A warrior handle.

Field Type Default Meaning
id String Required Local warrior ID.
template Warrior-template handle Omitted Template to inherit from.
first_name, last_name Localization handle or string "" The opponent’s name: a handle from sf2.localization.key, or literal text such as "Umbra".
avatar Sprite handle or string "" Opponent portrait: a sprite handle for art your mod ships, or an existing native portrait name.
voice String "" Existing voice identifier.
level Integer 0 Opponent level setting, 0–10,000; use an explicit level in a new opponent.
tactic Tactic handle or string "" Registered tactic or existing tactic name.
group String "" Opponent content group.
random Integer 0 Native random-selection setting.
items Item-handle array Empty Equipment/content loadout.
perks Array of perk handles or settings rows Empty Up to 64 active opponent perks; see below.
attributes Name-to-number table Empty Finite native attribute values.
attribute_alignments Alignment array Empty Rows described below.
body_model Model handle Inherit skeleton Native body model, including its ordered point rig.
skin_models Model handle array Empty Up to 16 native geometry overlays, appended after equipment. A warrior with skins is also offered as a look for Shadow in Mods > Characters; see Replace Shadow’s look.
health_bars Integer 0 Raid shield: the whole health pool in normal health bars, 0–10,000; 0 keeps the template setting.
visible_health_bars Integer 0 Number of stacked HUD bars the shield is drawn as, 0–100; 0 keeps the template setting.
skeleton String Inherit Native body item such as "Skeleton" or "SkeletonHeavy", added to the loadout.

Mod-authored body_model and skin_models receive a geometry preflight when the game loads the fighter. Unresolved points/edges, duplicate composed node or edge names, invalid helper definitions and unsafe numeric values reject before native geometry parsing, with a model/element/field diagnostic. Core handles keep legacy compatibility. See the geometry rules for defaults, limits and composition order. A valid model handle alone does not prove animation or collision compatibility.

local opponent = sf2.warriors.register {
id = "sparring_partner",
template = sf2.warriors.get_template("core:warrior-templates/default"),
first_name = "my.mod:localization/opponent.name",
level = 1,
tactic = "Standard",
-- Requires assets/sprites/partner.asset; core uses 512 x 512 portraits.
avatar = sf2.assets.sprite("sprites/partner"),
}

The avatar appears on the versus screen and fight HUD. A sprite handle is validated at registration and keeps working with any mod namespace. A plain string such as "character_savage" names a portrait in the game’s native UI/Users/ folder and is not validated. Core sprite handles also work, for example sf2.assets.sprite("core:ui/users/character_savage"), but only for portraits present in the packaged core art catalog.

Alignment rows require numeric factor and shift; optional priority defaults to 0 and mode to "all" ("normal" and "eclipse" are also accepted). Use documented native attributes for the relevant content; adding a made-up attribute name does not create a new mechanic. Template inheritance and native attribute handling can affect the result, so test custom balance in a fight.

Each perks entry can be a handle or { perk = handle, aspect = number, chance_factor = number, chance = number, frames = integer, parameters = table }. You may mix these forms in a dense array. Settings apply only to that warrior’s core perk instance; they do not change the core definition or other opponents. Configure owned Lua perks through their behavior definitions instead. Duplicate perks are rejected even when their settings differ.

All settings are optional. aspect accepts finite numbers from 0 to 2,147,483,647. chance_factor accepts finite numbers from 0 to 10,000; it is a native multiplier, not a 0–1 probability or guaranteed activation rate. chance is a finite probability from 0 to 1. frames is an integer duration from 0 to 2,147,483,647 in native simulation frames; it is not a wall-clock timeout. These override the native perk’s Chance and Frames parameters only where that perk uses them. They do not bypass the perk’s other activation conditions or change a perk that does not consume those parameters. Their effects depend on the selected native perk. Omitted fields inherit its defaults; explicit zero overrides a default. Changes to any setting change the saved content fingerprint. A bare handle and { perk = handle } are equivalent.

parameters sets any other native parameter the perk reads, as a table of up to 32 names mapped to finite numbers within ±1,000,000,000, for example { DamageFactor = 0.5, Health = 2 }. Names start with a letter and use at most 64 letters, digits or underscores. The dedicated names (Aspect, Chance, ChanceFactor, Frames, Name, Level, ApplyTo, Eclipse, Round) are rejected here; use the fields above. A name the perk does not read has no effect, so check the perk’s native parameters before relying on one.

local opponent = sf2.warriors.register {
id = "enchanted_partner",
template = sf2.warriors.get_template("core:warrior-templates/default"),
level = 1,
tactic = "Standard",
perks = {
{
perk = sf2.perks.get("core:perks/PERK_ITEM_SPECIAL_TIME_BOMB_WEAPON"),
aspect = 100000,
chance_factor = 2.69,
},
{
perk = sf2.perks.get("core:perks/PERK_ITEM_SPECIAL_ENFEEBLE_RANGED"),
aspect = 100000,
chance = 0.26,
},
{ perk = sf2.perks.get("core:perks/PERK_HERMITSTORM"), frames = 300 },
},
}

For a Blender import, animation bake, geometric skin export and playable move example, follow Character authoring. Body and skin assets must satisfy the native point-rig contract; these fields do not load arbitrary FBX files. The warrior handle also scopes moves through a character condition. Changing model bindings changes the saved content fingerprint.

skeleton names one of the game’s recovered body items: a name starting with Skeleton or SKELETON_, at most 64 letters, digits or underscores. It is written into the loadout like an item, so it matters mainly when items replaces an inherited list (see sf2.warriors.register_template). It is not a model handle; use body_model for authored bodies.

health_bars and visible_health_bars describe a raid shield, as the original game’s ShieldTotal and ShieldStack warrior attributes do. health_bars is the whole pool measured in normal health bars (0–10,000): the fighter’s maximum health becomes that number and damage is subtracted from it unchanged. visible_health_bars (0–100) is how many stacked bars the HUD draws, each holding health_bars / visible_health_bars. Zero inherits the template; the two inherit separately, so a warrior can raise a template’s shield and keep its bar count. Without visible_health_bars anywhere in the chain the whole shield is one bar. See creating an offline raid. Raid shields are separate from temporary Lua damage shields. A ShieldStack key in attributes is a legacy spelling of visible_health_bars; the typed field wins when both are set. Duplicate loadout items or perks are rejected.

Create a reward that a fight can grant through its normal result/save flow.

Signature: sf2.rewards.register { id, items?, choices?, gems?, experience?, prize_base?, currencies? }

Requires: content.register.

When: Entrypoint, before the fight uses the reward.

Returns: A reward handle.

Field Type Default Meaning
id String Required Local reward ID.
items Grant array Empty Guaranteed item grants: { item = handle, upgrade = 0, configure? = function }.
choices Choice-group array Empty Each group has items, a weighted candidate array.
gems Integer, 0–1,000,000 0 Fixed gem reward through normal acquisition.
experience Integer, 0–1,000,000 0 Experience points added by normal fight-result processing.
prize_base Finite number, 0–1,000,000 Omitted Native performance-bonus base, explained below.
currencies Array of drop rows Empty Forge-material drops, explained below.

experience is an absolute point count, not a level or percentage. Registering a reward does not grant it: the fight’s selected reward slot must reach the normal result/settlement flow. Native level caps and experience processing still apply.

prize_base supplies the native base used to calculate performance coin bonuses (such as critical hits and fighting style). It is not a fixed coin award. The native reward parser applies the encounter’s prize denomination scale. The result calculation then uses the base with its usual performance coefficients. Omitting the field preserves native fallback selection. A positive value selects this reward’s base; zero is preserved but still follows native fallback rules and does not guarantee zero coin bonuses. The API does not modify global reward coefficients or bypass settlement. Changing either field changes the saved content fingerprint; explicit zero experience is equivalent to omission.

local victory = sf2.rewards.register {
id = "story_victory", experience = 2, gems = 8, prize_base = 1,
}
-- Use victory in the appropriate wins slot of sf2.fights.register.rewards.

currencies adds forge-material drops, the materials used by the forge. Each row is { currency, expected, show? }: currency is "ForgeMaterial1", "ForgeMaterial2" or "ForgeMaterial3"; expected is the finite average amount, greater than 0 and at most 10,000,000, from which the game rolls the drop; show defaults to true and controls whether the result screen lists it. A reward holds at most 16 rows and each currency at most once.

local boss_reward = sf2.rewards.register {
id = "boss_victory", gems = 17,
currencies = {
{ currency = "ForgeMaterial1", expected = 50 },
{ currency = "ForgeMaterial3", expected = 9, show = false },
},
}

A candidate row is { item = handle, upgrade = 0, weight = 1, configure = function(context) ... end }. Upgrade is a nonnegative integer. Weights must be finite and greater than zero. Each group selects an item from its candidates. This is a content reward, not arbitrary script access to a currency balance.

Equipment grants may include configure, a bounded Lua callback evaluated when the host composes that reward result. It is available on both direct items and weighted choice candidates. Consumable, free and seal grants cannot use it.

local precision = sf2.perks.get("core:perks/PERK_ITEM_SPECIAL_PRECISION_WEAPON")
local titan_reward = sf2.rewards.register {
id = "titan_reward",
items = {
{
item = desolator,
configure = function(context)
return {
level = context.player_level,
enchantments = {
{ perk = precision, aspect = 1940.5, chance = 0.2,
parameters = { DamageFactor = 10000 } },
},
}
end,
},
},
}

The callback receives a fresh calculation snapshot:

Context field Type Meaning
player_level Integer Player level captured before reward XP is applied.
item_id String Qualified ID of the item being granted.

Return a table with either or both of these optional fields:

Result field Type Meaning
level Integer, 1–10,000 Equipment level for this grant snapshot. Defaults to context.player_level.
enchantments Dense array, at most 64 entries Grant-time enchantments. Each entry has a perk handle and optional settings below. Duplicate perk IDs are rejected.

Each enchantment entry accepts aspect (finite 0..2147483647), chance_factor (finite 0..10000), chance (finite 0..1), frames (nonnegative integer), and parameters (up to 32 named native numeric Set attributes, each finite and within ±1 billion). Names must be 1–64 letters, digits or underscores, starting with a letter; the dedicated setting names cannot appear in parameters. The native perk must actually read a named parameter for it to have an effect. Omitted settings stay omitted in the saved perk rather than receiving synthetic defaults. Perk handles must already have been looked up or registered during mod registration and captured by the callback. An explicit empty enchantments = {} adds no grant-time enchantments and still preserves the item’s normal acquisition defaults. Omitting enchantments also preserves those defaults. The existing upgrade field remains a separate nonnegative ordinal and is not derived from the returned level.

The selected level must exist in that item’s native progression. An unavailable level skips the configured grant with a diagnostic instead of lowering its level.

The callback is a calculation hook, not a general event handler. It runs with a 200,000-instruction budget and may also run for previews, so it must return the same result for the same context. Do not retain or mutate the context, mutate Lua captured state, or depend on call count. Capability-backed sf2 operations are blocked while the callback is running, so profile/state access, UI, navigation, content mutation and similar operations are unavailable. Logging remains allowed. The pure asset identity/existence helpers sf2.assets.qualify and sf2.assets.exists also remain available because they do not pass through the capability gate. Prefer captured handles plus ordinary Lua math for the result.

The host evaluates configuration after owned/missing-item filtering and makes a fresh native grant copy for the result. A thrown error, invalid return value, instruction-budget failure or disposed callback skips that item and reports a diagnostic. Other reward items and base currencies remain intact. The host does not silently fall back to the unconfigured item. Successful configuration then continues through the existing settlement and save path.

This is typed grant configuration only. It does not expose raw XML, expression strings or arbitrary item metadata. Static editor checks validate the callback shape and handles; they do not establish runtime rendering or art correctness.

Legacy XML reward items also support UpgradeLevel, which is an encoded native upgrade level rather than the ordinal UpgradeNumber used by Lua’s upgrade. For example, UpgradeLevel="?Player[].Level*100" evaluates through the native player-level expression resolver when the reward is selected. Selection uses the native quest-grant lookup: the first upgrade entry whose encoded level is at least the requested value. Negative values and unavailable upgrades fail reward selection; specifying both upgrade attributes fails parsing. Existing UpgradeNumber selection and clamping are unchanged. This compatibility support does not expose XML expressions through the Lua API or provide a complete interactive lottery workflow.

local no_reward = sf2.rewards.register { id = "no_reward" }
local victory_reward = sf2.rewards.register { id = "victory_reward", gems = 5 }

Connect a battle to characters, rounds, rules, and rewards.

Signature: sf2.fights.register(definition)

Requires: content.register.

When: Entrypoint, after the referenced definitions.

Returns: A fight handle.

Field Type Default Meaning
id String Required Local fight ID.
battle Battle handle Required Map entry this fight belongs to.
warriors Warrior-handle array Empty Opponent definitions.
player_character Warrior handle Omitted Optional temporary player character for this owned fight. Omit to use the saved player’s normal combat setup.
rules Rule-handle array Empty Fight rules from sf2.rules.
rewards Reward-handle array Empty Reward slots indexed by wins; see below.
rounds Integer, 1–100 3 Number of rounds.
round_time Integer, 1–86400 99 Time limit in seconds.
replays, replay_interval, power Nonnegative integers 0 Native replay, interval, and power settings.
location Location handle or ID The battle’s arena Arena: a location your mod registered, or a core ID such as "core:locations/dojo" from the Locations catalog.
music Audio handle or string "" Fight music reference: an audio handle from sf2.assets.audio or a native track name.
evaluated_rating Finite number -1 Native rating setting.
health_recovery Finite nonnegative number 1 Native health recovery setting.
description Localization handle or string "" Fight description: a handle from sf2.localization.key, or literal text.
reward_image String "" Reward presentation image reference.
locked Boolean false Initial lock setting.

player_character chooses the player at encounter creation, including its body, skins, level, attributes, equipment, perks, name and portrait. The fighter owns the native player slot and normal controls; its tactic does not turn it into an AI player. This is temporary combat content and does not equip items or replace the saved profile’s character. Native round rules still apply. Use an explicit character level and a compatible rig/move set. A referenced character must be registered and accessible when the content transaction commits.

This field belongs to owned sf2.fights.register definitions. It is not a sf2.fights.patch field or a general profile/loadout selection operation. A mode using the fight, including a generated encounter based on it, keeps this declared player character unless its prepared encounter plan supplies an owned player_character override. Use fighter:change_form for a later in-fight transition.

local fight = sf2.fights.register {
id = "training_fight", battle = battle,
rounds = 1, round_time = 99,
location = arena, -- a location handle from sf2.locations.register
warriors = { opponent },
rewards = { no_reward, victory_reward },
}

To start this owned encounter as a different playable character, register it before the fight and add player_character = hero to that fight table:

local hero = sf2.warriors.register {
id = "training_hero", level = 3,
template = sf2.warriors.get_template("core:warrior-templates/man_kungfu"),
}
-- In the existing sf2.fights.register table: player_character = hero

For a complete playable body, skin and character-specific Punch example, see Authored Fighter Lab.

Reward order matters. The first Lua element is the zero-win result slot. For a one-round fight, use { no_reward, victory_reward } so a loss does not accidentally receive the victory reward. Do not grant the same item again in a fight-end quest unless you intend a second reward.

For scheduled or replayable content, use events and modes to manage sequence progress and entry requirements.

Replace supported fields on an existing registered fight.

Signature: sf2.fights.patch { target, description?, rounds?, round_time?, location?, music?, warriors?, reward_drops?, rules?, append_rules? }

Requires: content.patch, and a dependency on the target owner.

When: Entrypoint, after the target mod has committed its definitions.

Returns: nil.

target is a fight definition ID string. Supply at least one changed field. description is a localization handle or literal text; rounds is 1–100; round_time is 1–86400 seconds. location takes a location handle or a location ID such as "core:locations/dojo". music accepts an audio handle from sf2.assets.audio for a mod-owned track, or a nonempty native track name. An audio handle checks that the packaged asset exists when the mod loads; a native name is resolved by the game when the encounter starts. These fields change the encounter’s presentation without changing its identity or progress.

Use append_rules = { rule, ... } to keep existing rules and add your own in array order. Use rules = { rule, ... } to replace the entire rule list, including native rules on a core encounter. rules = {} explicitly clears it; an empty append_rules is rejected. The two forms are mutually exclusive, accept at most 100 registered rule handles (also bounded to 100 after appending), and reject duplicate handles. Handles may be registered earlier in this same entrypoint. Both static rules and Lua behavior rules are supported.

warriors = { opponent, ... } replaces the entire opponent list. Supply 1–100 unique registered warrior handles, in encounter order. An empty list is rejected. These are warrior handles, not warrior-template handles or string IDs; you may register them earlier in the same entrypoint. Omitting warriors preserves the existing opponents. This field uses the normal warrior authoring contract, including templates, equipment and custom character models.

-- opponent is a handle returned by sf2.warriors.register earlier in this script.
sf2.fights.patch {
target = "core:fights/zone_1/boss_lynx/1",
warriors = { opponent },
}

Opponent replacement leaves encounter identity, rewards and saved campaign progress intact; it does not unlock or reset the fight. Two patches replacing the same fight’s opponents conflict. Opponent order contributes to the content fingerprint. Full-game opponent presentation and campaign replay acceptance remain pending; automated checks cover Lua registration, conflicts, preservation of other fields and native XML projection.

Appending preserves native XML rules that have no public handle. Their native execution remains unchanged; Lua rules run through the documented combat callback dispatcher. Array order orders Lua rules relative to other Lua rules, not native engine actions across different callback stages. The target must already exist in the exposed catalog: this is not an arbitrary XML patch and not a setter for a newly staged fight in the same transaction.

-- Replace the owner and ID with an existing fight from a declared dependency.
sf2.fights.patch {
target = "other.mod:fights/trial",
round_time = 120,
}

Competing changes to the same semantic field conflict. Different supported fields can coexist. Unsupported fields, missing targets, or undeclared owners fail registration.

Fight fields from one call are staged together: a validation failure discards that call’s fields, preserving earlier successful calls. A conflict discovered when committing the mod still rejects the entire registration transaction. The current Lua sandbox does not provide pcall/xpcall; an entrypoint error aborts loading rather than letting the script recover and continue.

append_rules from different mods compose in dependency load order, preserving each list’s order. Native XML projection preserves recovered entries first and then adds the combined static rules once. Lua behavior rules use the combined handle order at their existing combat dispatch boundary; they are not XML entries. Dependencies load before their dependents; among currently ready mods, IDs sort with an ordinal, case-sensitive comparison. Folder/discovery order is irrelevant. Lua rule callbacks use that combined order; order-dependent effects are not automatically commutative.

rules remains an exclusive replacement: it conflicts with any other rule-list patch, and later appends cannot add to its replacement. Diagnostics name the incoming mod and existing contributors. A mod can patch a fight’s rule field once per registration transaction. Duplicate attached handles, more than 100 combined handles, and overlapping round controllers still reject the entire registering transaction, including unrelated fields and definitions. Later independent compatible mods can continue loading.

Encounter IDs and saved campaign progress are preserved. The patch is reapplied from base definitions at startup; disabling the mod and restarting restores base content. Content fingerprints distinguish appended rules from replacement, including an empty replacement. Append records now identify their operation as append; older profiles using these patches can therefore report a changed content fingerprint after upgrading. State IDs and schemas are unchanged by this feature. See Combine mods for the tested three-mod example and removal/reinstallation limits.

local guard = sf2.rules.behavior { id = "guard", behavior = my_behavior }
sf2.fights.patch {
target = "core:fights/zone_1/boss_lynx/1",
append_rules = { guard },
location = "core:locations/dojo",
music = "fight1_samurai_spirit",
}

To supply music from the mod’s assets/audio/boss_theme.wav, patch with a handle instead:

local boss_theme = sf2.assets.audio("audio/boss_theme")
sf2.fights.patch {
target = "core:fights/zone_1/boss_lynx/1",
music = boss_theme,
}

my_behavior above must be a registered behavior handle. The complete Mods/example.core-fight mod demonstrates a health-dependent guard on an existing campaign opponent. These fields do not provide a generic XML patch interface or economy overrides. Reward editing is limited to the item-drop scopes below.

reward_drops accepts 1–100 scoped edits. Each edit replaces the selected scope’s direct item grants and item-only weighted choices. It does not replace the entire native reward row.

Field Type Default Meaning
wins Integer Required Existing zero-based reward slot, 0–100. Slot 0 is the zero-win result; slot 1 is one win. The slot must exist on the target fight.
reward Reward handle Required Registered reward supplying the replacement items/choices. Its gems must be zero. An empty reward clears direct drops.
mode "all", "normal", "eclipse" "all" Select the shared row or a mode-specific addition.
min_level Integer Omitted Inclusive lower player-level bound, 1–10,000.
max_level Integer Omitted Inclusive upper player-level bound, 1–10,000; must be at least the minimum.

With neither level bound, only the selected scope’s direct drops change. With one or both bounds, the edit selects a native level row with exactly those bounds, creating it when absent. Omitted bounds stay unbounded. Other level rows remain unchanged, including overlapping ranges: native settlement adds every matching row. Duplicate matching scopes are rejected as ambiguous.

Modes are additive. "all" edits the shared reward, which applies in both modes. "eclipse" edits only the extra Eclipse reward; shared items still apply. A missing mode scope is created within the existing result slot.

A mode scope does not select a different battle. Some vanilla encounters use a separate Eclipse battle through EclipseToggleName. Lynx, for example, links BOSS_LYNX to BOSS_LYNX_ECLIPSEMODE: target the latter for the replay encounter, as below. Patching one fight does not automatically patch its linked counterpart.

local sf2 = require("sf2")
local prize = sf2.rewards.register {
id = "eclipse_prize",
items = {{ item = sf2.items.get("core:items/weapon/WEAPON_C2_Z2_MONK_KATAR") }},
}
sf2.fights.patch {
target = "core:fights/zone_1/boss_lynx_eclipsemode/1",
reward_drops = {{ wins = 1, mode = "eclipse", reward = prize }},
}

The example requires content.register and content.patch, plus a dependency on core. It changes a reward definition, not completion conditions: it does not unlock the fight, reset progress or make an item a one-time grant. Native repeat/reward settlement rules still apply. The native result handler skips equipment the profile already owns; test with an unowned item or a separate test profile. Mod-owned consumables retain their supported repeat-grant behavior.

Money, premium currency, experience, reward scaling, lotteries, resistance and other scopes are preserved. A choice containing non-item outcomes is rejected: changing its item weights would also change currency probabilities. This operation does not edit lottery contents. Nested equipment configuration is available only through the reward grant’s typed configure callback described above, rather than through arbitrary metadata or XML edits.

Edits to the same fight/result/mode/exact-level scope conflict, including repeated edits by one mod. Distinct scopes coexist. All edits in one call share its rollback boundary. Content fingerprints include the scopes and reward identities; disabling the mod and restarting rebuilds the original definitions.

Lua registration, scoped projection and rollback have automated coverage. A native fixture also exercises the production item builders, item parser, weighted choice, mode/level reward composition, repeated evaluation and native result item selection, with controlled profile, inventory lookup, item upgrade and numeric services. It verifies preserved currency values, item upgrade/drop flags and independent lottery slot lists. Full-game reward display, inventory granting, replay eligibility and save/reload acceptance remain pending; these fixtures alone do not establish DE reward parity.