Animation

Animations define sequences of sprites that play over time. Pixelsrc supports two animation formats:

  • CSS Keyframes (recommended): Percentage-based keyframes with CSS timing functions
  • Frame Array (legacy): Simple list of sprite names

Both formats support palette cycling, frame tags, and secondary motion (attachments).


CSS Keyframes Format

The CSS keyframes format uses percentage-based keyframes, familiar to web developers and AI models. This is the recommended approach for new animations.

Syntax

{
  type: "animation",
  name: "fade_in",
  keyframes: {
    "0%": { sprite: "dot", opacity: 0.0 },
    "50%": { sprite: "dot", opacity: 1.0 },
    "100%": { sprite: "dot", opacity: 0.0 },
  },
  duration: "500ms",
  timing_function: "ease-in-out",
}

Keyframe Fields

FieldRequiredDefaultDescription
typeYes-Must be "animation"
nameYes-Unique identifier
keyframesYes-Map of percentage keys to keyframe objects
durationNo100Total animation duration (ms or CSS time string)
timing_functionNo"linear"CSS timing function for easing
loopNotrueWhether animation loops

Keyframe Object Fields

Each keyframe can specify any combination of these properties:

FieldDescription
spriteSprite name to display at this keyframe
opacityOpacity from 0.0 (transparent) to 1.0 (opaque)
offsetPosition offset [x, y] in pixels
transformCSS transform string (e.g., "rotate(45deg)") — see Transforms

Percentage Keys

Keyframe keys are percentages of the total animation duration:

  • "0%" - Start of animation
  • "50%" - Halfway through
  • "100%" - End of animation
  • "from" - Alias for "0%"
  • "to" - Alias for "100%"

Duration Format

Duration accepts both raw milliseconds and CSS time strings:

duration: 500        // 500 milliseconds
duration: "500ms"    // 500 milliseconds
duration: "1s"       // 1000 milliseconds
duration: "0.5s"     // 500 milliseconds

Timing Functions

The timing_function field accepts CSS easing functions:

FunctionDescription
linearConstant speed (default)
easeSmooth acceleration and deceleration
ease-inSlow start, fast end
ease-outFast start, slow end
ease-in-outSlow start and end
cubic-bezier(x1,y1,x2,y2)Custom bezier curve
steps(n, position)Discrete steps

Examples

Fade in animation:

{
  type: "animation",
  name: "fade_in",
  keyframes: {
    from: { sprite: "dot", opacity: 0.0 },
    to: { sprite: "dot", opacity: 1.0 },
  },
  duration: "1s",
  timing_function: "ease",
}

Walk cycle with opacity:

{
  type: "animation",
  name: "fade_walk",
  keyframes: {
    "0%": { sprite: "walk_1", opacity: 0.0 },
    "50%": { sprite: "walk_2", opacity: 1.0 },
    "100%": { sprite: "walk_1", opacity: 0.0 },
  },
  duration: "500ms",
  timing_function: "ease-in-out",
}

Rotating animation:

{
  type: "animation",
  name: "spin",
  keyframes: {
    "0%": { sprite: "star", transform: "rotate(0deg)" },
    "100%": { sprite: "star", transform: "rotate(360deg)" },
  },
  duration: 1000,
  timing_function: "linear",
}

Frame Array Format (Legacy)

The frame array format provides a simple list of sprite names. Use this for straightforward frame-by-frame animations.

Syntax

{
  type: "animation",
  name: "walk",
  frames: ["walk_1", "walk_2", "walk_3", "walk_4"],
  duration: 100,
  loop: true,
}

Fields

FieldRequiredDefaultDescription
typeYes-Must be "animation"
nameYes-Unique identifier
framesYes-Array of sprite names in order
durationNo100Milliseconds per frame
loopNotrueWhether animation loops

Frame References

Frames reference sprites or compositions by name. They must be defined earlier in the file:

{ type: "palette", name: "hero", colors: { ... } }
{ type: "sprite", name: "idle_1", size: [8, 8], palette: "hero", regions: { ... } }
{ type: "sprite", name: "idle_2", size: [8, 8], palette: "hero", regions: { ... } }
{ type: "animation", name: "idle", frames: ["idle_1", "idle_2"], duration: 500 }

Compositions as Frames

Animations can reference compositions, enabling layered character animation. This is useful when you want to animate individual body parts (arm, eyes, mouth) while keeping the base body static:

// Layer sprites
{"type": "sprite", "name": "body", ...}
{"type": "sprite", "name": "arm_down", ...}
{"type": "sprite", "name": "arm_wave1", ...}
{"type": "sprite", "name": "arm_wave2", ...}

// Compositions combining body + arm positions
{"type": "composition", "name": "char_pose1", "size": [32, 48], "cell_size": [32, 48],
  "sprites": {"B": "body", "A": "arm_down"},
  "layers": [{"map": ["B"]}, {"map": ["A"]}]}

{"type": "composition", "name": "char_pose2", "size": [32, 48], "cell_size": [32, 48],
  "sprites": {"B": "body", "A": "arm_wave1"},
  "layers": [{"map": ["B"]}, {"map": ["A"]}]}

{"type": "composition", "name": "char_pose3", "size": [32, 48], "cell_size": [32, 48],
  "sprites": {"B": "body", "A": "arm_wave2"},
  "layers": [{"map": ["B"]}, {"map": ["A"]}]}

// Animation using compositions as frames
{"type": "animation", "name": "wave", "frames": ["char_pose1", "char_pose2", "char_pose3", "char_pose2"], "duration": 200}

This approach avoids duplicating the body sprite for each animation frame.

Palette Cycling

Animate by rotating palette colors instead of changing sprites. This classic technique creates efficient water, fire, and energy effects.

{
  type: "animation",
  name: "waterfall",
  sprite: "water_static",
  palette_cycle: {
    tokens: ["water1", "water2", "water3", "water4"],
    fps: 8,
    direction: "forward",
  },
}

Palette Cycle Fields

FieldRequiredDefaultDescription
spriteYes*-Single sprite to cycle (*required if no frames)
palette_cycleYes-Cycle definition object or array
palette_cycle.tokensYes-Ordered list of tokens to rotate
palette_cycle.fpsNo10Frames per second for cycling
palette_cycle.directionNo"forward""forward" or "reverse"

Multiple Cycles

Run several palette cycles simultaneously:

{
  palette_cycle: [
    { tokens: ["water1", "water2", "water3"], fps: 8 },
    { tokens: ["glow1", "glow2"], fps: 4 },
  ],
}

Frame Tags

Mark frame ranges with semantic names for game engine integration:

{
  type: "animation",
  name: "player",
  frames: ["idle1", "idle2", "run1", "run2", "run3", "run4", "jump", "fall"],
  fps: 10,
  tags: {
    idle: { start: 0, end: 1, loop: true },
    run: { start: 2, end: 5, loop: true },
    jump: { start: 6, end: 6, loop: false },
    fall: { start: 7, end: 7, loop: false },
  },
}

Tag Fields

FieldRequiredDefaultDescription
tagsNo-Map of tag name to tag definition
tags.{name}.startYes-Starting frame index (0-based)
tags.{name}.endYes-Ending frame index (inclusive)
tags.{name}.loopNotrueWhether this segment loops
tags.{name}.fpsNoinheritOverride FPS for this tag

Tags allow game engines to play specific sub-animations by name.

Per-Frame Metadata

Define hitboxes and metadata that vary across frames:

{
  type: "animation",
  name: "attack",
  frames: ["attack_1", "attack_2", "attack_3"],
  frame_metadata: [
    { boxes: { hit: null } },
    { boxes: { hit: { x: 20, y: 8, w: 20, h: 16 } } },
    { boxes: { hit: { x: 24, y: 4, w: 24, h: 20 } } },
  ],
}

Frame 1 has no hitbox (null), while frames 2 and 3 have active hit regions.

Secondary Motion (Attachments)

Animate attached elements like hair, capes, or tails that follow the parent animation with configurable delay:

{
  type: "animation",
  name: "hero_walk",
  frames: ["walk_1", "walk_2", "walk_3", "walk_4"],
  duration: 100,
  attachments: [
    {
      name: "hair",
      anchor: [12, 4],
      chain: ["hair_1", "hair_2", "hair_3"],
      delay: 1,
      follow: "position",
    },
    {
      name: "cape",
      anchor: [8, 8],
      chain: ["cape_top", "cape_mid", "cape_bottom"],
      delay: 2,
      follow: "velocity",
      z_index: -1,
    },
  ],
}

Attachment Fields

FieldRequiredDefaultDescription
attachmentsNo-Array of attachment definitions
attachments[].nameYes-Identifier for this attachment
attachments[].anchorYes-Attachment point [x, y] on parent sprite
attachments[].chainYes-Array of sprite names forming the chain
attachments[].delayNo1Frame delay between chain segments
attachments[].followNo"position""position", "velocity", or "rotation"
attachments[].dampingNo0.8Oscillation damping (0.0-1.0)
attachments[].stiffnessNo0.5Spring stiffness (0.0-1.0)
attachments[].z_indexNo0Render order (negative = behind parent)

Follow Modes

ModeBehavior
positionChain follows parent position directly
velocityChain responds to movement velocity
rotationChain responds to rotation changes

Duration vs FPS

You can specify timing using either duration (ms per frame) or fps (frames per second):

{ type: "animation", name: "fast", frames: [...], duration: 50 }
{ type: "animation", name: "fast", frames: [...], fps: 20 }

Both examples create the same 20 FPS animation.

Complete Example

A blinking light with fade effect:

// Light sprites
{
  type: "palette",
  name: "blink",
  colors: {
    _: "transparent",
    on: "#FFFF00",
    off: "#888800",
  },
}

{
  type: "sprite",
  name: "light_on",
  size: [4, 4],
  palette: "blink",
  regions: {
    on: {
      union: [
        { rect: [1, 0, 2, 1] },
        { rect: [0, 1, 4, 2] },
        { rect: [1, 3, 2, 1] },
      ],
    },
  },
}

{
  type: "sprite",
  name: "light_off",
  size: [4, 4],
  palette: "blink",
  regions: {
    off: {
      union: [
        { rect: [1, 0, 2, 1] },
        { rect: [0, 1, 4, 2] },
        { rect: [1, 3, 2, 1] },
      ],
    },
  },
}

// Fade animation
{
  type: "animation",
  name: "blink_fade",
  keyframes: {
    "0%": { sprite: "light_on", opacity: 1.0 },
    "50%": { sprite: "light_off", opacity: 0.5 },
    "100%": { sprite: "light_on", opacity: 1.0 },
  },
  duration: "1s",
  timing_function: "ease-in-out",
}

Rendering Animations

# Render as animated GIF
pxl render animation.pxl -o output.gif

# Render as spritesheet
pxl render animation.pxl --format spritesheet -o sheet.png

# Preview with onion skinning
pxl show animation.pxl --onion 2

Migrating from Frames to Keyframes

Converting from the legacy frame array format to CSS keyframes is straightforward:

Basic Migration

Before (frames format):

{
  type: "animation",
  name: "walk",
  frames: ["walk_1", "walk_2", "walk_3", "walk_4"],
  duration: 100,
  loop: true,
}

After (keyframes format):

{
  type: "animation",
  name: "walk",
  keyframes: {
    "0%": { sprite: "walk_1" },
    "25%": { sprite: "walk_2" },
    "50%": { sprite: "walk_3" },
    "75%": { sprite: "walk_4" },
  },
  duration: "400ms",
  loop: true,
}

Key Differences

AspectFrames FormatKeyframes Format
Timingduration is per-frameduration is total animation time
StructureFlat sprite arrayPercentage-keyed objects
PropertiesSprite onlySprite, opacity, offset, transform
EasingN/Atiming_function for interpolation

Migration Steps

  1. Calculate total duration: Multiply per-frame duration by frame count

    • 4 frames × 100ms = 400ms total
  2. Convert to percentages: Divide frame index by total frames

    • Frame 0 → 0%
    • Frame 1 → 25% (1/4)
    • Frame 2 → 50% (2/4)
    • Frame 3 → 75% (3/4)
  3. Wrap sprites in keyframe objects: "walk_1" becomes { sprite: "walk_1" }

  4. Add timing function (optional): Use timing_function: "linear" for frame-accurate timing

When to Migrate

Migrate to keyframes when you need:

  • Opacity changes: Fade effects between frames
  • Position offsets: Screen shake, bouncing
  • Transforms: Rotation, scaling effects
  • CSS timing: Easing curves for smoother motion

Keep using frames format for:

  • Simple frame-by-frame animations with no interpolation
  • Quick prototypes
  • Backwards compatibility with existing files