Validating a Mod

The game is forgiving about malformed mod data. Unknown fields are ignored, missing files are treated as "this mod provides nothing here", and a broken reference fails at the moment it is used rather than at load. That is good for robustness and bad for finding mistakes: a typo usually shows up as an invisible sprite or a silent sound effect, several minutes into a playtest.

Two tools close that gap.

JSON Schemas

Machine-readable schemas for every JSON format live in docs/schemas/.

Schema Validates
config.schema.json config.json
spriteSettings.schema.json Entities/*/spriteSettings.json
animations.schema.json Entities/*/animations.json
logic.schema.json Entities/*/logic.json, including every logic event
dependencies.schema.json Entities/*/dependencies.json
level.schema.json Levels/level <n>.json
level-art.schema.json - art.json, - cutscenes.json, title.json, presentation.json, ending.json
audio-replacements.schema.json music-replacements.json, sound-replacements.json
music-loop.schema.json Musics/<track>.json
common.schema.json Shared value types, referenced by the others

They are JSON Schema draft 2020-12 and reference each other by file name, so keep them in one folder.

Every field carries a description, which makes them useful as a lookup as well as a check. Fields marked deprecated are ones older builds wrote and the runtime ignores: they validate so existing mods do not drown in noise, but do not write new ones.

The schemas are checked against every mod in the repository's Editor/ folder, so they accept what the tools actually produce, not an idealised version of it.

Using them

With Python:

pip install jsonschema
import json
from jsonschema import Draft202012Validator
from referencing import Registry, Resource
from referencing.jsonschema import DRAFT202012
import glob, os

resources = {
    os.path.basename(p): Resource.from_contents(json.load(open(p, encoding="utf-8")),
                                                default_specification=DRAFT202012)
    for p in glob.glob("docs/schemas/*.json")
}
registry = Registry().with_resources(resources.items())

validator = Draft202012Validator(resources["logic.schema.json"].contents, registry=registry)
data = json.load(open("Editor/My Mod/Entities/My Fighter/logic.json", encoding="utf-8-sig"))
for error in validator.iter_errors(data):
    print("/".join(str(p) for p in error.absolute_path), error.message)

Most editors also accept a $schema pointer or a workspace mapping, which gives you completion and inline errors while hand-editing.

The validator script

tools/validate_mod.py runs the schemas plus the cross-file checks a schema cannot express.

python tools/validate_mod.py "Editor/My Mod"

Options:

Option Effect
--schemas <path> Where the schema folder is. Found automatically in the usual layouts.
--strict Exit non-zero on warnings too, not just errors.
--no-collapse List every finding instead of collapsing repeats of the same one.

It needs jsonschema for the schema pass and optionally Pillow for palette size checks. Without either, the remaining checks still run.

Findings come in three levels:

  • ERROR — something is broken and will not work: a sprite that draws nothing, an animation id past the end of the list, a sound that does not exist.
  • WARN — suspicious but possibly deliberate: an animation whose logic duration outruns its frames, an undeclared dependency, a level whose art has more scenes than its gameplay.
  • NOTE — informational, including checks that were skipped because they needed a pack that is not part of this mod.

Repeats of the same finding in the same file collapse to one line with a count, so one systemic problem reads as one problem.

What it checks beyond the schemas

Entities

  • pivots length against the numbered PNGs, erroring only when an animation actually references a missing frame and noting the rest.
  • imageId and iconIndex against the real sprite count, accounting for borrowed reference frames.
  • animationsLogic length against animations.
  • Every animationId in events and throw offsets against the animation count.
  • Every sound name against Sounds/, and every script name against Scripts/.
  • Event $type spelling, unknown event classes, and input combo strings containing characters that can never match.
  • Frame numbers past the end of an animation, and negative frames left behind by editor frame shifts.

Levels

  • Every imageName against Levels/Art/.
  • Every layer animationId against the file's animationsDefinition.
  • Scene counts between a level and its art file.
  • wallLimitsData length, and the horizon/depth constraint when the left wall flag is set.
  • Loadcues with no entityName, and collision boxes stored in the shape the game cannot read.
  • Scene music against Musics/.

Palettes

  • Duplicate or non-contiguous palette indices, and files that do not end in a number.
  • Entity palette guides numbering fewer than two, which silently disables palette switching.
  • Palette images of differing sizes within one set, with Pillow installed.

Audio and config

  • Replacement maps pointing at audio that does not exist, and empty maps that are silently skipped.
  • Loop end before loop start, and loop files with no matching track.
  • dependenciesChecksums and dependenciesIds lengths against dependencies.
  • id and each dependenciesIds entry being a UUID.
  • Difficulty entries with too few per-player-count settings.
  • Any pack referenced anywhere in the mod but missing from dependencies.

What it cannot check

Anything that needs the other pack's contents. If your entity borrows Classic Axel's art, the validator cannot know how many frames Classic Axel has, so it skips the sprite index checks for that entity and says so in a note. The same applies to sounds and entities referenced from another mod.

It also says nothing about whether your mod is any good: a hitbox in the wrong place, a jump that feels bad, or a palette with muddy colours all validate perfectly.

Runtime regression checks

Construct character fixtures through the normal spawn/initialization path and retain the flags it produces. Setting convenient flags by hand can hide a script that excludes every real enemy. For example, enemy initialization leaves the item/projectile is_active flag clear; it does not mean that the enemy is absent.

Exercise the whole frame pipeline, including the victim controller, position resolution and animation update. Checking only the attacker's script state or pending animation does not establish the frame or position that is drawn. Record both characters' rendered sprite, pivot, position and height at each choreography transition and at throw release. Include different victim packs, both facings, wall-adjacent grabs, and enemies with extra health bars. Start grab moves after several different hold durations, and test a player victim both before and after the attacker in player-slot order. A victim that updates first can observe the previous attacker's clock even when a later-slot victim behaves correctly.

For input regressions, use the normal player controller's input preprocessing. Smooth controls can replay a press through host spam timers; a Lua controller that keeps its own input latch must consume the host buffer when it captures the press. Test one press, a held button, a fresh second press and hit interruption.

For cinematics, assert health and death transitions while the fire is landing, not merely a queued hit flag or a result after the event ends. Check the complete camera trip, including a return to zero, with actual frame timing when testing interpolation. Keep these scratch harnesses and extraction scripts outside the committed mod and production tools unless they are intended as maintained tooling.