Skip to main content

Keep Animation Holds When Moving a Sprite Between Tools

Updated 5 min readMarkdown
Keep Animation Holds When Moving a Sprite Between Tools

An animation can contain all the right images and still feel wrong because its importer replaced unequal frame holds with one frame rate. Preserve the ordered frame-duration pairs, their units and the playback multiplier. Then compare the cycle length and the hold pattern separately. Matching only the average speed can conceal a lost anticipation or impact pose.

Key Takeaways

  • A sheet of pixels does not, by itself, describe how long each image stays on screen.
  • Absolute milliseconds, relative frame weights and added delays are different contracts.
  • Verify an unequal-duration fixture before importing the entire animation library.

Start with three visibly different frames

Create three labeled poses: A, B and C. Give them intended durations of 50, 50 and 200 milliseconds. Use forward looping playback with no transition blend and a speed multiplier of one. The complete cycle should last 300 milliseconds, while C occupies two-thirds of that cycle.

This is a synthetic fixture, not a recommendation for a character's cadence. Unequal values make a conversion fault obvious. Three frames at 100 milliseconds each would preserve the total cycle while destroying the intended rhythm.

Keep source frame identifiers alongside durations. Filename sorting is not an animation specification: pose10 can land before pose2 under a lexical sort. A compact atlas may also reuse one raster for repeated poses without implying that a time interval disappeared.

Write the handoff record before the adapter

Use an explicit intermediate record, such as the following conceptual JSON. It is an application-owned format for this example, not an Aseprite, Godot or PixiJS export schema.

{
  "loop": "forward",
  "durationUnit": "ms",
  "frames": [
    {"id": "A", "duration": 50},
    {"id": "B", "duration": 50},
    {"id": "C", "duration": 200}
  ]
}

Record direction and endpoint behavior as well. Reversing or ping-pong playback can alter which endpoints repeat. Aseprite's CLI documentation describes export selection and naming controls; inspect the emitted data from the installed version rather than assuming the PNG carries its timeline. The tag turnaround guide addresses a separate endpoint-order check.

Reject negative, zero or non-finite values unless the destination has an explicitly supported meaning for them. JSON serialization should not silently turn an invalid duration into a usable-looking value. See the non-finite duration check for that boundary.

Convert units deliberately

Godot's SpriteFrames reference defines per-frame duration as a relative value. At 20 animation frames per second and playback speed one, weights 1, 1, 4 produce the intended 50, 50, 200 milliseconds. Passing 50, 50, 200 as relative weights would multiply the entire schedule.

For PixiJS 8.14 AnimatedSprite, use the frame-object form when preserving unequal holds. The PixiJS-maintained AnimatedSprite reference specifies { texture, time } with time in milliseconds. This fixture therefore passes 50, 50, 200 as frame times and keeps animationSpeed at one. A plain array of textures does not contain those duration values. Confirm the installed API before translating the example to another release.

Representation Values for A, B, C Required context
Absolute milliseconds 50, 50, 200 Unit is ms; multiplier is one
Relative weights 1, 1, 4 Base rate is 20 fps; speed is one
Equal-rate approximation 100, 100, 100 Same total, wrong hold pattern

An adapter should document the destination's meaning before assigning a property named duration. Similar names do not guarantee equivalent units. If a destination combines a base interval with an extra delay, sending an absolute duration as that delay adds time twice.

Check the math without launching an engine

This illustrative Python 3 example verifies only the conversion arithmetic:

durations_ms = [50, 50, 200]
fps = 20
weights = [ms * fps / 1000 for ms in durations_ms]
recovered_ms = [weight / fps * 1000 for weight in weights]
assert weights == [1.0, 1.0, 4.0]
assert recovered_ms == [50.0, 50.0, 200.0]
assert sum(recovered_ms) == 300.0
print(weights, sum(recovered_ms))

Now validate the real adapter. Log the imported frame IDs and configured durations before playback. Require A, B and C in order, the four-to-one C/A hold ratio, and a 300-millisecond nominal cycle at multiplier one. Reject the import if any value was silently dropped.

Observe playback without overclaiming precision

A displayed frame can remain visible until the next presentation opportunity. Therefore, engine timing logs and a screen recording do not have identical resolution. Record the engine version, update loop and capture rate when judging visible holds.

Use a long enough repeated sample to spot a sustained rate mismatch, but investigate skipped updates separately. The Bevy missed-tick guide explains one update-loop hazard. A correct metadata conversion does not establish how another engine catches up after a stall.

Evidence and editorial responsibility

This AI-assisted article is maintained by FrameSprite Engineering, which has a commercial interest in the product. See the team and editorial policy and contact page. Sources were consulted on 2026-10-30. The fixture and arithmetic are illustrative, not recorded engine results.

About the author

  • FrameSprite team publication: technical guides for image processing, sprite workflows and game development. Our AI-generated guides identify their sources, executed synthetic fixtures and testing limits. Original guides: https://www.framesprite.com/guides

No comments yet. Be the first to comment.