Classic Game Mechanics¶
How the original game works underneath the mod files. Read this when a custom entity has to interoperate with Classic content: a custom fighter that Classic enemies grab and throw, a custom enemy that Classic players throw, a container that drops a Classic weapon, or a character built on Classic Axel that gains a run.
Everything here was read from the runtime source and the ROM data, and is stated in the units the JSON files use. Where the game has two rule sets, "original" means the untouched ROM behaviour and "improvements" means the Anniversary rules (hasImprovements in config.json).
Coordinate systems and units¶
There are four different unit systems, and mixing them up is the most common cause of a move that "does nothing".
| Where | Unit | 1 pixel equals |
|---|---|---|
Sprite pivots, hitboxes, body boxes, throw offsets, PositionOffsetEvent, level spawn positions, weapon overlay offsets |
pixels | 1 |
walkSpeed, runSpeed |
1/256 px per frame | 256 |
VelocityEvent, AccelerationEvent, jumpForce, SpawnItemEvent position and velocity, every Lua position/velocity/impulse field |
1/1000 of an internal unit | about 65.5 |
throwLogic.releaseVelocity.y |
raw internal units (16.16 fixed point) | 65536 |
Internally, positions are 16.16 fixed point: 65536 per pixel. The 1/1000 unit is that raw value divided by 1000, which is why editor-written values are in the thousands. A useful rule of thumb: 65.5 units is one pixel per frame of speed, and 5000 units is about 76 pixels of distance.
Axes¶
The game runs at 60 frames per second on a 320×224 screen (400 wide in widescreen).
- x is horizontal along the level. In every entity-relative field, positive x is forward, the way the entity faces. The game mirrors it when the entity faces left. Author everything as if facing right.
- y in level and spawn data is depth: the floor line the entity stands on. Larger is nearer the camera (lower on screen). The walkable band runs from the scene's far wall limit down to 216.
- Height above the floor is a separate value. In JSON events and
jumpForce, positive y is up. In Lua it isposition_offset_y, where negative is up and0is on the ground; the same sign flip applies to Luaimpulse_y(negative rises). - z in
VelocityEvent/AccelerationEventis depth movement.VelocityEventnegates z,AccelerationEventdoes not.
Sprite pivots¶
spriteSettings.json pivots are in pixels from the bottom-left corner of the PNG: x from the left edge, y from the bottom edge. A standing frame whose feet touch the bottom row has a pivot y of about 0 or 1. The Character Editor shows and edits the same value. Getting this backwards puts the character a full sprite height into the floor.
Hitboxes¶
Custom content uses combatBounds for each hitbox/bodybox and globalCombatBounds for the default hurtbox:
"combatBounds": {
"enabled": true,
"xMin": 0, "xMax": 13,
"heightMin": 0, "heightMax": 51,
"depthMin": -8, "depthMax": 8
}
X and height describe an arbitrary rectangle relative to the feet. Height is positive upwards; the character's jump height is added automatically. Depth is a separate interval on the belt plane. X mirrors automatically when facing left, so the example becomes -13..0. Odd widths, negative heights, offsets of zero, and asymmetric depth are valid. Edges are inclusive. enabled: false suppresses collisions, including a per-frame override of an enabled global box. A zero-height rectangle remains a line rather than being interpreted as disabled. Bounds work in both Classic and improved gameplay modes and do not use the legacy air/knockdown shortcuts. Author those shapes explicitly per animation when needed.
The Godot editor shows X/Y as the bottom-left corner and Width/Height as full dimensions. It saves only the canonical bounds fields. Old hitBox, boundingBox, and globalBodyBox fields remain readable. For unchanged imported legacy boxes the editor retains migration metadata (legacyHitRules or legacyHeightRules) so re-saving does not silently change Classic line hits, inherited attack depth, or legacy body collision rules. Editing the rectangle, depth, or enabled state clears that metadata and gives the box normal rectangular semantics. New content should omit it.
Legacy-only contacts retain their original scanners: Classic hitboxes at x = y = 0 use the height line, attacks derive depth from the attacker, and bodyboxes use centered half widths. With improvements off, legacy hitboxes retain the original screen-coordinate tests. Those rules apply to compatibility data, rather than defining the custom rectangle format.
Hit sparks¶
The spark is chosen by damage: 8 or more gives BigHit, less gives SmallHit. It is placed relative to the attacker, in pixels, y up: for a Classic line hitbox at the tip of the line, for a custom box at a random point inside the overlap with the victim. Container hits anchor the spark on the container. Projectile hits draw no spark. The BlueHit effect is loaded but never spawned by gameplay code.
Other offsets¶
- Throw offsets
offset {x, y}are pixels. See Grabs and throws for the sign convention. - Weapon overlay
position {x, y}is pixels with negative x forward and negative y up, the opposite of everything else. Classic Axel's standing knife is at-62y. PositionOffsetEventis pixels, x forward. Itsyis added to the depth velocity, not to height. Usezfor depth teleports andVelocityEventfor height.SpawnItemEventpositionOffsetandvelocityare the 1/1000 unit, +x forward, +y up.{2750, 2750}is about 42 px forward and 42 px up; a velocity of 250 is about 3.8 px per frame.lifetimeof0means 60 frames, not "never expire".SpawnEnemyEvent.animationIdis passed raw whereSpawnItemEvent.animationIdis doubled. If a spawned enemy starts in the wrong animation, that is why.
Movement and velocity¶
Each frame the engine adds persistent impulses into this frame's displacement, moves, then applies gravity:
velocity.x += impulse.x (horizontal)
velocity.y += impulseZ (depth)
position += velocity (velocity is cleared afterwards)
height += impulse.y (only while airborne)
impulse.y += gravity
- Gravity is 0.25 px per frame per frame (0x4000 raw), applied only while airborne. There is no terminal velocity.
VelocityEventwrites the impulses:x × facing × 1000intoimpulse.x,-y × 1000intoimpulse.y,-z × 1000intoimpulseZ. An all-zero event stops the entity. WithuseAxisFlagsonly the flagged axes are written.jumpForceis in the same unit asVelocityEvent.y. A jump setsimpulse.y = -jumpForce × 1000andimpulse.x = 0; in the air the original code accelerates horizontally by 0.25 px per frame per frame up to a cap of 2 px per frame, whatever the walk or run speed. The Test chronicle scriptssor3AirControland friends exist to raise that cap for a running jump.walkSpeedandrunSpeedare 1/256 px per frame:256is 1 px per frame. Walking on the depth axis is half the horizontal speed.
Classic player values:
| Character | walkSpeed |
runSpeed |
jumpForce |
frontGrabOffset / backGrabOffset |
|---|---|---|---|---|
| Max | 256 (1.0 px/f) | 0 | 311 (4.75 px/f) | 32 / 24 |
| Axel | 320 (1.25 px/f) | 0 | 311 (4.75 px/f) | 40 / 16 |
| Blaze | 384 (1.5 px/f) | 0 | 327 (5.0 px/f) | 32 / 16 |
| Skate | 416 (1.625 px/f) | 832 (3.25 px/f) | 327 (5.0 px/f) | 24 / 24 |
Other Classic player defaults: jumpDelayFrames 4, comboRepetitions 2, grabComboRepetitions 2, chargeDelay 50. An Axel jump peaks at about 47 px and lasts about 38 airborne frames plus the 4-frame crouch.
Fixed-point aside: the Lua fields position_x, position_offset_y, impulse_x and so on are the raw value divided by 1000 with no sign flip, so Lua impulse_y < 0 means rising.
Health, damage and special moves¶
- A player life is 104 health (0x68). The HUD draws one tile per 4 health.
- Defensive special (special button with no direction, slot 35) costs 8 health, only if it connected. A whiffed defensive special is free. This is the original rule, not an improvement.
- Offensive special (forward + special, slot 37) costs 8 health always, charged when the move ends. With improvements, Axel can cancel his between ticks 32 and 76 without a target for a cost of 2, and a player grabbed by an enemy mid-special loses 4.
- Blitz (double-tap forward + attack, slot 36) is free.
- A special is refused when health is 8 or lower.
These costs are applied by the Classic move controllers, so a custom character gets them for free when its slots 35 and 37 inherit Classic behaviour (no events). As soon as an animation has events, the Classic controller is skipped and nothing charges health. The equivalent is a HealthEvent with negative health, which is unconditional; "no hit, no cost" needs a JumpToAnimationOnHit into a second animation that carries the HealthEvent. Axel 3 in Test chronicle does the unconditional form on its slot 48: HealthEvent at frame 29 with health: -8.
Reference damage values from the ROM, for tuning a custom character to feel Classic (KD marks a knockdown):
| Move | Axel | Max | Blaze | Skate |
|---|---|---|---|---|
| Punch (29) | 6 | 16 / 8 | 4 | 4 |
| Third punch (30) | 8 | – | 6 ×3 | 4 |
| Combo end A / B (31 / 32) | 10 KD; 10 then 14 KD | 32 / 16 / 16 KD | 10 KD, 8, 8 KD | 8, 16 KD, 16 KD |
| Grab attack (33) | 8 | 10 | 8 | 6 |
| Grab combo end (34) | 8, 10 KD | 12, 16 KD | 16 KD | 16 KD |
| Jump attacks (26 / 27 / 28) | 10, 20 KD / 8 KD / 12 | 20, 32 KD / 12 KD / 16 | 24 KD / 8 KD / 12 | 24 KD / 16 KD / 24 |
| Defensive special (35) | 16 KD per hit | 24 KD per hit | 24 KD | 16 KD |
| Blitz (36) | 24, 4, then 20 KD ×4 | 16 KD, 16 | 40 KD, 16, 24 KD | 40 KD then 24 KD ×8 |
| Offensive special (37) | 6–10 per hit, ending 20 KD, 24 KD | 16 / 30 KD alternating, then 16 KD ×2 | 44 / 36 / 28 / 20 KD, 12 | 8 ×5, 24 KD |
| Back attack (38) | 8, 12 KD | 8, 16 KD | 8 KD ×3 | 16, 24 KD ×3 |
| Hold-attack charge (49) | 15, 21 KD | 48 / 24 / 24 KD | 15 KD, 12, 12 KD | 12 ×3 |
Galsia's punch is 4, his uppercut 8 KD, and his weapon swing 24 KD. Thrown weapons and thrown bodies do 8 and 16 respectively unless stated otherwise below.
Enemy health in a level spawn is nibble-encoded: total = (health & 0xF) + (health >> 4) × 12, then the difficulty multiplier, then lives = total / 12 (at most 4) and the visible bar is (remainder + 1) × 8. A health of 2 is 24 hit points; 0 is 8 hit points, not an entity default.
Animations a Classic-compatible character needs¶
Required contract for imported fighters¶
Every imported playable character must retain the SOR2 interaction slots below, even when its source game has different animation IDs or a custom move controller. In particular, provide front/back grabbed poses (6–7), grab-hit and ordinary hit reactions (8–9), knockdown/lying/get-up (10–12), and all six incoming throw poses (18–23), including suplexes and Max's atomic drop. Do not reuse these slots for source-game attacks. Append additional attacks after the reserved Classic slots.
Copy the per-frame durations, not just the total duration, from the victim tables below. Classic throwers use the victim's timeline to select placement and release; redistributing the same total across frames changes the choreography. Verify incoming interactions with Axel, Max, Blaze, Skate and enemy throwers in both facings, using the complete runtime frame pipeline.
For a uniformly scaled import, record one source-to-SOR2 spatial scale and apply
it to art, pivots, hit/hurt boxes, movement distances, velocities, accelerations,
projectile offsets and throw choreography, after converting each field's units.
spriteSettings.scale affects rendering only: it does not scale combat or physics.
Preserve source frame durations and input windows; time, health and damage are
not spatial quantities. SOR2 belt depth and incoming Classic throw choreography
are adaptation rules, so document them separately from scaled source values.
Do not claim source accuracy for estimated or unmeasured values.
The slot table in Enums and Identifiers lists every id. This section says which ones the Classic controllers will actually jump to, so that a missing slot does not leave the entity frozen or invisible.
An id past the end of animationsLogic is reset to slot 0. A null slot inside the range ends instantly, has no body box, and logs an error; some code paths then throw. If your own animationsLogic is non-null it does not fall back to the reference's, so every slot below must exist on the entity itself.
For anything Classic can hit and grab (players and enemies):
| Slot | Used when | Classic durations |
|---|---|---|
| 0 Standing | Idle. | any |
| 1 Walk | Walking in any direction; enemies also use it for walking backwards. | any |
| 2 Jump | In the air. | [4, 25, 40] on players |
| 6 Being front grabbed | Held from the front. Held pose, one frame. | [12] players, [8] enemies |
| 7 Being back grabbed | Held from behind. | [12] players, [8] enemies |
| 8 Being grab hit | Hit while held. | [3, 3, 3, 3] players, [3] ×10 enemies |
| 9 Being hit | Hit while standing. | [3, 3, 3, 3] players, [3] ×10 enemies |
| 10 Knocked down | Flying backwards after a knockdown hit. | [12] |
| 11 Lying down | On the floor; also the pose on landing from a throw, and the death pose. | [40] players, [20] enemies, [30] Blaze |
| 12 Stand up | Getting up. | [8, 8, 10, 10] players, [8, 8, 8, 8] enemies |
| 13 Electrocuted | Hit by the electric floor or Electra; also the "get up from lying intro" for enemies. | [4, 4] |
| 15 Land from jump | Landing frame. | [3] |
| 16 Land from jump kick | Landing after an aerial attack. | [3] |
| 17 Fall | Falling without a jump (pits, ledges). | [40] |
| 18–23 | Thrown by a Classic character. See the next section. | fixed |
Additionally for players: 3 grab jump or vault, 4 grab front, 5 grab back, 14 pick up, 24 front grab throw, 25 back grab throw, 26–28 jump attacks, 29–32 the punch chain, 33–34 grab attacks, 35 defensive special, 36 blitz, 37 offensive special, 38 back attack, 39 static grab attack, 42–46 weapon attacks (see Weapons), 49 hold-attack. The character select screen also needs a portrait.png and preview.png.
Additionally for a Galsia-type enemy: 29 punch, 30 uppercut, 31 weapon swing, 27–28 knife Galsia attacks, 32–34 the manhole and sitting introductions. Other Classic enemies use their own higher slots; copy the slot list from the ROM-imported version of the enemy you reference (the Character Editor's ROM import produces one).
Grabs and throws¶
Being grabbed¶
When a Classic character grabs a victim, the victim is snapped to the grabber's frontGrabOffset or backGrabOffset (pixels; the victim's own heldFrontGrabOffset / heldBackGrabOffset win when set) and put into slot 6 or 7. The side is the grabber's: once it has taken its front or back hold, the victim is put in the matching slot and faced toward (front) or away from (back) the grabber, even if a script AI turned it on the grab tick. Grab attacks put it into slot 8 for their duration. The grabber selects the choreography, while the victim's being-grabbed controller also runs to check held hits, the relationship and throw initiation. A custom controller must cooperate with both updates; those three victim slots provide the held poses.
Throw choreography¶
A throw is authored on the thrower, in the throw animation's throwLogic. Every frame the victim is placed and animated from the thrower's offsets:
| Field | Meaning |
|---|---|
offsets[].frame |
Animation tick (game frames elapsed, not image index) at which this keyframe starts. The runtime adds 1 before comparing. |
offsets[].offset |
Victim position relative to its anchor, in pixels. Negative y is up. x is mirrored so that negative x is in front of the victim; for a front throw, where the victim faces the thrower, that is behind the thrower. |
offsets[].opponentAnimation |
Animation id forced on the victim, every frame. |
offsets[].opponentFrame |
Tick the victim's animation is held at, every frame, when useLegacyHardcodedMechanics is false. |
opponentFacesSameDirection |
false for front throws (victim faces the thrower), true for back throws. Also picks which grab offset anchors the victim. |
releaseVelocity.x |
Whole pixels per frame, pushed toward the victim's own back. |
releaseVelocity.y |
Raw 16.16 fixed point, negative is up: -180224 is 2.75 px per frame upward. Not the 1/1000 unit. |
releaseDamage |
Applied when the victim lands, not at release. Difficulty multipliers apply, and a player thrown by a player takes half. |
useLegacyHardcodedMechanics |
See below. |
Release happens on the last offset, once the tick reaches its frame. At release the victim keeps whatever animation it was forced into, enters the knockout state, and falls with gravity from the height it was held at. On landing it switches to slot 11 (lying), bounces 1.75 px per frame, shakes the screen by thrownQuakeEffect, takes the damage, and then plays slot 12. A held-and-thrown body also knocks down anything it passes through: 16 damage originally (32 if Max threw), or min(releaseDamage, 40) with a floor of 16 under improvements. A victim with Up + Jump held at landing time lands safely when the throw gave it upward velocity.
useLegacyHardcodedMechanics does not skip the offsets. It changes the clock: with it on, the offsets are indexed by the victim's animation tick and opponentFrame is ignored, so the victim's own frame durations decide the timing. This is how every ROM throw works. With it off, the thrower's tick indexes the offsets, opponentFrame freezes the victim on a chosen tick for each keyframe, and enemy victims are re-anchored to the thrower's position plus grab offset every frame. Author new throws with it off; it is the only mode where opponentFrame does anything.
Set opponentAnimation to the victim slot you mean. Several test entities in the shipped Editor/Test chronicle leave it at 0, which forces the victim into Standing for the whole throw. That is a bug in the test data, not a convention.
What Classic throwers do to a custom victim¶
When a Classic player or enemy throws your entity, its throwLogic decides the victim's animation, so the victim animation slots 18–23 must exist with the right shape. Which one is picked on the first thrown frame:
- Front grab: Blaze's slam (slot 39 and above) uses 21; Max's air throw (slot 40 and above) uses 22; every other front throw uses 18.
- Back grab: Max, Blaze, Zamza and Abadede use 20; Skate uses 18; Axel and everyone else use 19.
- Max's atomic drop uses 23.
Because ROM throws run on the victim's clock, the victim animation must be at least as long as the release tick, or it loops and the throw never releases (a player victim is then dropped with no damage when the thrower's animation ends). The safe approach is to copy the Classic durations exactly. They are the same for Axel, Max, Blaze, Skate, Galsia and Signal:
| Slot | Frame durations | Total | Poses, in order |
|---|---|---|---|
| 18 Front slammed by Max | [16, 4, 6, 20, 99] | 145 | held (hit pose), held, head-down diagonal lift, held upside down, upside down in flight |
| 19 Back slammed by Axel | [16, 4, 6, 3, 99] | 128 | held (hit pose), held, held, knocked-down pose, lying on back in flight |
| 20 Back slammed by Max | [16, 4, 20, 99] | 139 | held (hit pose), held, upside down, upside down in flight |
| 21 Front slammed by Blaze | [16, 8, 99] | 123 | being-hit pose, knocked-down pose, lying in flight |
| 22 Thrown by Max | [10, 99] | 109 | lifted horizontally, hit pose in flight |
| 23 Being atomic dropped | [99] | 99 | hit pose (Max lands the damage with a 70 KD hitbox) |
Donovan's slot 18 is [16, 4, 6, 10, 99]. The last, 99-tick frame is the airborne pose the victim keeps until it lands; everything before it is the held part. Where each Classic throw releases the victim, in victim ticks:
| Thrower and move | Victim slot | Offsets (tick: x, y) | Release tick | Release velocity (px/f up, px/f back) | Damage |
|---|---|---|---|---|---|
| Max front throw (24) | 18 | 0: −16,0 · 16: −32,−32 · 20: −72,−40 · 26: −104,0 · 46: −104,0 | 46 | 0, 2 | 34 |
| Max back throw (25) | 20 | 0: −8,0 · 16: 32,−32 · 20: 80,0 · 40: 88,0 | 40 | 0, 2 | 62 |
| Max air throw (40, from the vault) | 22 | 0: −24,−88 · 10: 16,−32 | 10 | 2.0, 2 | 40 |
| Axel front throw (24) | 18 | 0: −16,0 · 16: −32,−8 · 20: −56,−24 | 20 | 2.75, 4 | 24 (28 improved) |
| Axel back slam (25) | 19 | 0: −16,0 · 16: 16,−8 · 20: 16,−40 · 26: 32,−16 · 29: 40,0 | 29 | 0, 2 | 28 (40 improved) |
| Blaze front throw (24) | 18 | 0: −16,0 · 16: −8,0 · 20: −56,−24 · 26: −88,−24 | 26 | 3.75, 3 | 28 |
| Blaze back throw (25) | 20 | 0: −16,0 · 16: 16,−8 · 20: 48,8 · 40: 48,0 | 40 | 0, 2 | 32 (40 improved) |
| Blaze front slam (39) | 21 | 0: −24,0 · 16: 0,0 · 24: 0,0 | 24 | 0, 2 | 32 |
| Skate vault throw (41) | 18 | 0: −8,−8 · 16: −8,−16 · 20: 0,−40 | 20 | 3.75, 3 | 30 |
| Barbon throw (24) | 18 | 0: −8,−8 · 16: −8,−24 · 20: −48,−48 · 26: −64,−64 | 26 | 3.75, 3 | 16 |
| Signal throw (24) | 18 | 0: −8,−8 · 16: −8,−24 · 20: −48,−48 · 26: −64,−56 | 26 | 3.75, 3 | 12 |
So, for example, Max's front throw holds your entity's slot 18 through its first four frames (ticks 0–45) and releases it on the 99-tick frame; Axel's front throw releases at tick 20, so the victim flies through frames 2 and 3 and usually lands before frame 4 shows. Draw the poses accordingly: for slot 18, frame 2 is the horizontal lift and frames 3–4 are head-down; for slot 19, frame 3 is the suplex impact and frame 4 the lying pose.
When adapting another game's throws, compare the actual sprite orientation at
each source phase. Slot 19's later poses are falling and lying, not inverted;
slot 18 supplies the head-down poses. A custom back throw can switch between
those slots through opponentAnimation. Preserve the standard victim sequences
for incoming throws from other packs. A source release sentinel may retain the
previous sprite instead of selecting a new pose; verify its controller branch.
Classic thrower animations are [16, 4, 6, 20, 4] for Max, Axel and Blaze slot 24 and for Max and Blaze slot 25; Axel's slot 25 back slam is [16, 4, 6, 3, 16, 10, 6, 15]; Blaze's slot 39 is [16, 8, 16, 16, 15]. Copy these when a custom fighter's throws should look and time like Classic ones, and reuse the victim slot ids above as opponentAnimation so Classic victims animate correctly.
Max's vault is special: pressing attack while rising and facing the victim gives the air throw (Max slot 40, victim 22); pressing it while falling and facing the victim's back gives the atomic drop (Max slot 41, victim 23), which has no throwLogic and does its 70 damage with a knockdown hitbox on landing.
Running¶
Running is a double tap forward: the second tap must land within 14 frames of the first (with smooth controls, in the same direction). While running, attack gives the blitz (slot 36) and jump is an ordinary jump (horizontal speed is reset to 0 and capped at 2 px per frame in the air).
Whether an entity can run is decided by one thing: runSpeed > 0 in its effective logic. There is no mode flag. In Classic only Skate runs (832); the Anniversary pack gives Shiva 800. A mod entity that inherits Classic Axel inherits runSpeed 0 and cannot run until it sets its own.
The run animation is slot 40. That slot is Skate's run and Max's air throw in the Classic table, and it is unused on Axel and Blaze, which is why it is free to become their run cycle. Skate's is [4, 6, 10, 4, 6, 10].
Giving Axel a run¶
This is exactly how Editor/Test chronicle/Entities/Axel 3 does it:
- In
logic.json, keepreferencePackName: "Classic",referenceEntityName: "Axel", and set"runSpeed": 640(2.5 px per frame; Skate's 832 is 3.25). - Add the run cycle sprites and, in
animations.json, fill slot 40 with them. Axel 3 uses six images at [6, 8, 7, 6, 8, 7]. - In
logic.json, give slot 40 a plain entry with noevents:{ "name": "Run", "duration": 42, "hitboxes": [], "bodyBoxes": [], "events": [] }. The Classic standing controller moves the character; the animation only needs to exist.
That is enough for running. Axel 3 goes further so that a running jump carries momentum, because Classic jumps cap air speed at 2 px per frame:
- slot 40 has
"script": { "packName": "Test chronicle", "scriptName": "sor3SetRunAirMaxVelocity" }, which setscustom_value_1 = 2and returnstrue; - slot 1 (walk) has
sor3SetWalkAirMaxVelocity, which sets it to1; - slots 2, 27 and 28 (jump and aerial attacks) run
sor3AirControl, which reads that value and drivesimpulse_xitself: walk tier starts at 50 and accelerates 16 per frame up to 110; run tier starts at 100 and accelerates 20 up to 190 (about 2.9 px per frame). It returnstrueso the Classic jump keeps handling gravity and landing.
The same recipe works for Blaze and Max, and for any enemy whose reference has a free slot 40.
Pick-up items and weapons¶
An item's behaviour is decided by its perceived Classic identity: the chain of referencePackName/referenceEntityName in logic.json. entityType alone changes nothing. A pick-up that does not resolve to a Classic weapon or goodie cannot be picked up at all.
| Reference | Effect on pick-up |
|---|---|
Cash Bag |
+1000 score |
Gold Bars |
+5000 score |
1UP |
+1 life |
Apple |
+32 health, capped at 104 |
Chicken |
full health |
Knife, Pipe, Sword, Kunai, Grenade |
held as that weapon |
The amounts are hard-coded; a custom food item chooses between apple and chicken by which one it references. Pick-up needs the item on or within 60 px of the ground, active and vulnerable.
Weapons pick the holder's attack animation by type: Knife uses slot 42, Pipe 43, Sword 45, Kunai 46. If the slot is missing the game falls back to 42, and if that is missing too, nothing happens. Blaze with improvements uses slot 50 for the knife. A weapon survives three drops (grabComboCount starts at 3) and then blinks away. Thrown weapon speeds are knife 8, pipe 5, sword 4, kunai 6, grenade 3 px per frame, released 60 px ahead and 52 px up, on tick 7 of the thrower's weapon throw animation for Axel, 12 for Blaze, 5 for Skate and 9 for the others (an animationsLogic entry's weaponThrowFrame overrides it); a thrown weapon always knocks down and does 8 damage by default.
Weapon animation slots on the item itself, following the Classic Knife: 0–7 are the held sprite per WeaponAngle (the holder's overlay direction × 2 selects them), 10 is spinning in flight, 11 is lying on the ground. Knife and kunai fly using slot 6, pipe, sword and grenade using slot 10. Goodies only need slot 0. Item modes are (controller index + 1) × 2: 2 none, 4 falling, 6 flying, 8 on the ground, 10 blinking away; goodies vanish once scrolled 16 px off the left edge.
Level placement of a loose item: status_2: 5, mode_7: 2, animation_6: 0.
Containers¶
A container is an item entity (entityType 3 in every shipped example) whose logic references a Classic container such as Bin, Crate, Oil Barrel, Chair or Arcade. It needs two animations: slot 0 intact, slot 1 broken. Editor/Test chronicle/Entities/Test Box (references Chair) and Test box 2 (references Arcade) are minimal working examples.
- Any hit breaks it. Containers have no health.
- On breaking it switches to slot 1, flies 1 px per frame sideways and 2 px per frame up under normal gravity, blinks for 40 frames, then disappears. Sounds: Oil Barrel SE47, Alien Egg SE48 (and it explodes), a few use SE49, everything else SE14.
- What it contains is set in the level, not in the entity. The item loadcue's
containedItemPackNameandcontainedItemEntityNamename the drop;insideForTwoPlayersOnly_10_b7restricts it to two-player games. With improvements the drop appears immediately; otherwise after the 40-frame break. - Level placement:
status_2: 3(vulnerable + obstacle),mode_7: 2,animation_6: 0. Setting bit0x80instatus_2makes an inert decoration instead. - A custom container's
globalBodyBoxis ignored. Items consult their legacy body box first, so define the hurt volume in per-animationbodyBoxeson slot 0. The loadcuecollisionBox_3_4_5is also ignored for items. - A dropped item lands like the Classic item it is or references: weapons start in slot 6, mode 8 (lying on the ground, then blinking away off screen); goodies and other custom items get the goodie defaults (animation 0, mode 2). Custom drops keep an 8×8×8 box.
- A custom container, goodie, or weapon lying on the ground runs its referenced Classic controller in animations without events, keeping its own identity and art. Flying, held and thrown custom items keep their custom handling.
Enemies and AI¶
Which controller runs¶
An enemy's AI comes from its logic reference. LocalEnemyController runs the Classic controller of the perceived entity for the enemy's current objectMode; the per-character mode tables are in Behaviour Modes. Per frame, for a custom enemy:
- the current animation's
events(if any, with default physics, and the Classic controller is skipped for that animation); - the entity-level
aiScript(returningfalsestops here); - the animation's
script(returningfalsestops here); - the animation's
referenceBehaviour; - the Classic controller for the current
objectMode.
entityType 1 (BasicEnemy, a "commoner") versus 2 (Enemy) only affects wave progression: up to two commoners may be left alive when the next wave triggers. Classic commoners are Galsia, Donovan, Signal and Biker.
aiData¶
The ROM AI table is an array of rows, one per aggressiveness level, 32 columns (40 for Shiva). Columns 0–4 are the probabilities of the enemy's five decision modes, the rest are minimum and random timers, speeds and distances per mode. The row is picked by the spawn's enemyAgressiveness, clamped to the last row (Galsia has 11 rows, Barbon 6). A null aiData falls through to the reference, which is what a custom enemy should normally do. From Lua the five probabilities can be overridden per instance with override_aiData_E0 … E4.
Spawn fields that shape behaviour¶
enemyAgressiveness: the loadcue value plus half the difficulty's enemy level; once that half reaches 5 the result jumps by 15 instead. Galsia only picks up weapons at 21 or more.health: nibble-encoded, see Health. There is no respawn count; extra lives come from the encoding.introductionTypefor Galsia and Donovan: 0 normal, 1 sitting, 2 out of a manhole, 3 knife Galsia, 4 pipe Donovan, 5 lying on the floor, 6 and 7 falling from the sky, 8 facing right.useBossSlot: reserves the boss slot, shows the boss bar, and uses the boss health multiplier.triggerType3 waitstriggerArgument × 2frames. An enemy with a non-zerotriggerArgumentspawns screen-relative: x from the screen's left edge, andy: 0means "at the player's depth".minimumDifficulty × 2must not exceed the session difficulty or the enemy is skipped.
Custom AI with aiScript¶
Put "aiScript": { "packName": "<pack>", "scriptName": "<file>" } next to animationsLogic in the enemy's own logic.json. It is not inherited through the reference. Its update function runs every frame in every state, sees the enemy as game.this_enemy, and either augments the Classic AI (return true) or replaces it for the frame (return false). A replacing script must do the work the Classic controller would have done that frame: at minimum call check_being_hit() so hits still land, and apply_default_physics() or its own movement.
The Lua surface most AI scripts need:
- targeting:
get_nearest_opponent(same_line, forward, backward),homing_target_id,position_x,position_y(depth),position_offset_y(height, negative up); - state:
check_being_hit(),check_being_grabbed(),is_animation_over,current_animation_frame,object_mode,mode,mode_timer,enemy_agressiveness; - acting:
set_animation(id, frame),is_facing_right,direction_signal,velocity_x,impulse_x,impulse_y,stop_movement(),return_to_standing(),try_spawn_item(...),try_grab(...); - memory:
custom_value_1…custom_value_10, which persist per instance.
Distances in Lua are the 1/1000 unit: 5000 is about 76 px. Worked examples, including a full replacement AI, are in Enemy AI Examples. The shipped Shinobi enemy in Test chronicle shows the other style: it references Galsia and drives its standing state with an animation-level script instead.
Related pages¶
- Entity Files for the fields this page gives meaning to.
- Enums and Identifiers for the slot table and Classic entity names.
- Behaviour Modes for
objectModevalues. - Level Files for spawn and item loadcues.
- Lua Scripting for the scripting API.