Animation

Table of Contents

Downstroke animates sprites by swapping an entity's #:tile-id on a timed cycle. You declare a table of named animations on the entity under #:animations, pick one by name with #:anim-name, and the engine's last pipeline stage — apply-animation — advances the frame counter every tick.

There is no interpolation, no blending, no tween layer: each frame is a whole tile from the tileset, selected by index, held for a fixed number of ticks. The model is small enough to describe in one line of data and small enough to disable per-entity when you want to drive #:tile-id by hand.

This file assumes you already know how entities are shaped (see entities.org) and how the update pipeline runs (see physics.org).

1. The minimum you need

An animated entity needs three things:

  1. A #:tile-id (so there is something to render before the first tick).
  2. A non-empty #:animations list.
  3. A #:anim-name that names one of those animations.

The simplest hand-built entity looks like this:

(list (cons #:type 'player)
      (cons #:x 80) (cons #:y 80)
      (cons #:width 16) (cons #:height 16)
      (cons #:tile-id 28)
      (cons #:anim-name 'walk)
      (cons #:anim-frame 0)
      (cons #:anim-tick 0)
      (cons #:animations
            (list (list (cons #:name 'walk)
                        (cons #:frames '(28 29))
                        (cons #:duration 10)))))

In practice you never write this by hand. You declare the animation in a prefab data file and let load-prefabs + instantiate-prefab build the alist for you:

;; demo/assets/animation-prefabs.scm
((mixins)
 (prefabs
  (hero animated
        #:type hero #:anim-name walk
        #:animations ((#:name walk #:frames (28 29) #:duration 10)))))

Two things are happening here that are not obvious:

  • The animated mixin (from engine-mixins) supplies the #:anim-frame 0, #:anim-tick 0, and #:tile-id 0 defaults so you don't have to.
  • The prefab loader deep-converts the plist-shaped #:animations value into a list of alists (see entities.org for why prefab data is written as plists but entities are alists). #:animations is one of the keys in +nested-plist-list-keys+ that gets this treatment.

Once the prefab is in a scene, the engine does the rest — you don't need to call anything per frame just to make the animation play.

2. Core concepts

2.1. Animation data shape

An animation is an alist with these keys:

Key Required Meaning
#:name yes Symbol used to select this animation.
#:frames yes List of frames (see below).
#:duration no Default per-frame budget for bare frame
    entries (see Duration resolution).

2.2. #:frames layouts (prefab plist style)

You can combine bare tile ids and per-frame pairs in the same #:frames list, but each element must be unambiguous:

  • Bare ids — every element is a tile id on its own (number or symbol, depending on your data). All such frames share one budget: the animation's #:duration, or the global default if the animation omits #:duration.

    (#:name attack #:frames (1 2 3) #:duration 100)
    
  • Per-frame budgets — a frame is a two-element proper list '(tile-id budget). That frame always uses budget; the animation's top-level #:duration does not apply to it. Lists with more than two elements are not treated as timed frames (they fall through to the bare-id rules and will not behave as intended).

    (#:name walk #:frames ((1 100) (2 300) (3 25)))
    
  • Bare ids, animation-wide default omitted — same as the first case, but the budget for every bare frame is the global default +default-anim-duration+ (100 in animation.scm, same units as engine dt — typically milliseconds).

    (#:name idle #:frames (1 2 3))
    

Uniform example (bare frames, shared #:duration):

(#:name attack #:frames (28 29) #:duration 10)

The #:animations entity key holds a list of these animation records, one per named animation:

#:animations ((#:name idle #:frames (10))
              (#:name walk #:frames (28 29) #:duration 10)
              (#:name jump #:frames (30)))

For a real working example of timed vs uniform frames side by side, see the timed-frames and std-frames prefabs in demo/assets/animation-prefabs.scm.

2.3. Duration resolution

For the frame at index i in #:frames, animation-frame-duration resolves the tick budget in this order (and advance-animation always uses this — there is no separate “entity override” for the threshold):

  1. Per-frame — if the entry is a two-element list '(tile-id budget), use budget.
  2. Animation default — otherwise use the animation alist's #:duration when present.
  3. Global default — otherwise +default-anim-duration+ (100 in animation.scm).

The same resolution runs on every apply-animation tick: the accumulated #:anim-tick is compared to (animation-frame-duration anim (entity-ref entity #:anim-frame)), and #:anim-duration on the entity is set to that resolved value every step so it always reflects the budget for the current frame index (whether the frame advanced this tick or not).

2.4. The apply-animation pipeline step

apply-animation is the last stage of default-engine-update, after physics, ground detection, entity collisions, and group sync. You can see the wiring in engine.scm:

(scene-map-entities _ (cut apply-animation <> <> dt))

It is defined with define-pipeline under the pipeline name animation:

(define-pipeline (apply-animation animation) (scene entity dt)
  guard: (entity-ref entity #:animations #f)
  ...)

Two things fall out of this definition:

  1. The guard. If an entity has no #:animations value, the pipeline returns the entity unchanged. You do not have to opt out of animation for non-animated entities — not declaring the key is the opt-out.
  2. Skip support. Because the step is registered under the pipeline name animation, any entity with #:skip-pipelines containing animation is also returned unchanged. Same mechanism as the physics skips described in physics.org.

When the step does run, animate-entity looks up the current animation by #:anim-name in the entity's #:animations list, and advance-animation does the actual work:

  • Resolve the current frame's tick budget with animation-frame-duration (see Duration resolution); mirror that value onto #:anim-duration on the entity.
  • Accumulate #:anim-tick by the frame delta dt (same units as the budgets above).
  • If the tick reaches or exceeds that budget, advance #:anim-frame (modulo the number of frames), reset #:anim-tick to 0, update #:anim-duration to the new frame's resolved budget, and write the new frame's #:tile-id onto the entity.
  • Otherwise, write the current frame's #:tile-id, the incremented tick, and keep #:anim-duration equal to the current frame's resolved budget (unchanged value unless you changed #:frames or animation #:duration out of band).

The renderer reads #:tile-id directly — so as long as the pipeline ran, what ends up on screen is always the current frame's tile.

Running the animated pipeline after physics means your visual state reflects the entity's post-physics position and flags for the frame. If you want to swap animations based on state (e.g. walking vs jumping), your user update: hook — which runs after physics too — is the right place.

2.5. Switching animations

You change what an entity is playing by setting #:anim-name. The helper for this is set-animation:

(set-animation entity 'walk)

set-animation has one important subtlety: if the requested name matches the entity's current #:anim-name, it is a no-op. The entity is returned unchanged — tick and frame counters keep their values. This is what you want almost all the time: calling (set-animation entity 'walk) every frame while the player is walking should not restart the walk cycle on frame 0 each tick.

If the name is different, set-animation resets both #:anim-frame and #:anim-tick to 0 so the new animation plays from its first frame.

The typical usage pattern is in your update: hook, branching on input or physics state:

update: (lambda (game dt)
  (let* ((scene  (game-scene game))
         (input  (game-input game))
         (p0     (car (scene-entities scene)))
         (anim   (cond ((not (entity-ref p0 #:on-ground? #f)) 'jump)
                       ((input-held? input 'left)             'walk)
                       ((input-held? input 'right)            'walk)
                       (else                                  'idle)))
         (p1     (set-animation p0 anim)))
    (game-scene-set! game (update-scene scene entities: (list p1)))))

Because set-animation returns a fresh entity alist (it calls entity-set three times internally), the result must be written back into the scene. See entities.org for the immutable-update convention.

2.6. Interaction with the animated mixin

engine-mixins in prefabs.scm includes a convenience mixin named animated:

(animated #:anim-name idle
          #:anim-frame 0
          #:anim-tick 0
          #:tile-id 0
          #:animations #t)

Listing animated in a prefab gets you all the bookkeeping keys for free. You almost always want this — it saves repeating #:anim-frame 0 #:anim-tick 0 on every animated prefab.

Two of these defaults are booby-traps:

  1. #:animations #t — a placeholder. The pipeline guard only checks that the value is truthy, so #t is enough to make the step run; but the #t itself is obviously not a valid animation list. You must override #:animations with a real list on any prefab that uses animated. If you forget, animation-by-name will fail (it calls filter on a non-list) the first time the step runs.
  2. #:anim-name idle — also a default. The pipeline will then look up an animation named idle in your #:animations list. If you don't define one, animation-by-name returns #f, animate-entity returns the entity unchanged, and #:tile-id is never written by the pipeline. Your entity will render with whatever static #:tile-id it happens to have — which, since the mixin sets #:tile-id 0, is usually tile 0 (a blank or unexpected tile). The symptom is "my sprite is the wrong tile and never animates" with no error.

The animation demo's prefabs show both ways to avoid this. Neither defines an idle animation; both override #:anim-name to match the animation they do define:

(timed-frames animated #:type timed-frames #:anim-name walk
              #:animations ((#:name walk #:frames ((28 10) (29 1000)))))
(std-frames   animated #:type std-frames   #:anim-name attack
              #:animations ((#:name attack #:frames (28 29) #:duration 10)))

The rule is simple: either define an idle animation, or override #:anim-name to a name you actually defined.

3. Common patterns

3.1. Declaring walk/idle/jump in a prefab

The usual player or enemy prefab declares all the animations it might switch between in one #:animations list:

(player animated physics-body
        #:type player
        #:width 16 #:height 16
        #:tile-id 28
        #:anim-name idle
        #:animations ((#:name idle  #:frames (28))
                      (#:name walk  #:frames (28 29) #:duration 8)
                      (#:name jump  #:frames (30))))

Note that this prefab does define an idle animation, so the animated mixin's default #:anim-name idle works as-is — no override needed. If the player is standing still and update: never calls set-animation, the engine will happily play the single-frame idle cycle forever.

A single-frame animation (like jump above) is the idiomatic way to hold a static pose: the cycle advances, the modulo wraps back to frame 0 every tick, and #:tile-id stays pinned to the one tile.

3.2. Switching animation based on input

Put the decision in your update: hook, after you've read input state but before you hand the entity back to the scene:

(define (choose-anim entity input)
  (cond ((not (entity-ref entity #:on-ground? #f)) 'jump)
        ((or (input-held? input 'left)
             (input-held? input 'right))          'walk)
        (else                                     'idle)))

update: (lambda (game dt)
  (let* ((scene  (game-scene game))
         (input  (game-input game))
         (p0     (car (scene-entities scene)))
         (p1     (set-animation p0 (choose-anim p0 input))))
    (game-scene-set! game (update-scene scene entities: (list p1)))))

Two details worth noticing:

  • set-animation is safe to call every frame with the same name; it won't reset the cycle.
  • apply-animation runs as part of default-engine-update, which runs before your user update: hook. An #:anim-name change you make in update: takes effect on the next frame's apply-animation call, not this one — a one-frame latency that is imperceptible in practice.

3.3. Per-frame durations for non-uniform timing

Use the two-element '(tile-id budget) frame form when you want a cycle where one frame lingers and another flashes past. The canonical example (from demo/assets/animation-prefabs.scm) is a blink-heavy idle:

(#:name idle #:frames ((28 10) (29 1000)))

Tile 28 shows for 10 ticks (a quick flash), then tile 29 holds for 1000 ticks (a long eye-open pose), then the cycle repeats. Per-frame budgets are step 1 in Duration resolution; they override the animation's top-level #:duration, which in turn overrides the global default for bare frames only.

3.4. Disabling animation on an entity without touching #:animations

If you want to freeze an animated entity on its current frame — for example, pausing an enemy during a cutscene — the cheapest way is to list animation in its #:skip-pipelines:

(entity-set entity #:skip-pipelines '(animation))

This leaves #:animations, #:anim-name, #:anim-frame, #:anim-tick, and #:anim-duration untouched. When you remove animation from #:skip-pipelines later, the cycle resumes exactly where it left off. Contrast with (entity-set entity #:animations #f), which strips the animation data entirely and would require you to rebuild it to resume.

You can combine animation with any of the physics pipeline names in the same skip list — they all share one #:skip-pipelines key. See physics.org for the full list of step names.

4. See also

Author: Gene Pasquet

Created: 2026-04-20 Mon 15:22

Validate