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:
- A
#:tile-id(so there is something to render before the first tick). - A non-empty
#:animationslist. - A
#:anim-namethat 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
animatedmixin (fromengine-mixins) supplies the#:anim-frame 0,#:anim-tick 0, and#:tile-id 0defaults so you don't have to. - The prefab loader deep-converts the plist-shaped
#:animationsvalue into a list of alists (see entities.org for why prefab data is written as plists but entities are alists).#:animationsis 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 usesbudget; the animation's top-level#:durationdoes 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 inanimation.scm, same units as enginedt— 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):
- Per-frame — if the entry is a two-element list
'(tile-id budget), usebudget. - Animation default — otherwise use the animation alist's
#:durationwhen present. - Global default — otherwise
+default-anim-duration+(100 inanimation.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:
- The guard. If an entity has no
#:animationsvalue, 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. - Skip support. Because the step is registered under the pipeline
name
animation, any entity with#:skip-pipelinescontaininganimationis 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-durationon the entity. - Accumulate
#:anim-tickby the frame deltadt(same units as the budgets above). - If the tick reaches or exceeds that budget, advance
#:anim-frame(modulo the number of frames), reset#:anim-tickto 0, update#:anim-durationto the new frame's resolved budget, and write the new frame's#:tile-idonto the entity. - Otherwise, write the current frame's
#:tile-id, the incremented tick, and keep#:anim-durationequal to the current frame's resolved budget (unchanged value unless you changed#:framesor animation#:durationout 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:
#:animations #t— a placeholder. The pipeline guard only checks that the value is truthy, so#tis enough to make the step run; but the#titself is obviously not a valid animation list. You must override#:animationswith a real list on any prefab that usesanimated. If you forget,animation-by-namewill fail (it callsfilteron a non-list) the first time the step runs.#:anim-name idle— also a default. The pipeline will then look up an animation namedidlein your#:animationslist. If you don't define one,animation-by-namereturns#f,animate-entityreturns the entity unchanged, and#:tile-idis never written by the pipeline. Your entity will render with whatever static#:tile-idit 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-animationis safe to call every frame with the same name; it won't reset the cycle.apply-animationruns as part ofdefault-engine-update, which runs before your userupdate:hook. An#:anim-namechange you make inupdate:takes effect on the next frame'sapply-animationcall, 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
- guide.org — the minimal-game walkthrough.
- entities.org — entity alists, the prefab
system, and how
#:animationsis deep-converted from plist data. - physics.org — the rest of the update
pipeline and the
#:skip-pipelinesmechanism. - tweens.org — when you want interpolation rather than discrete frame-swapping.
- Animation (
bin/demo-animation) — prefab data in demo/assets/animation-prefabs.scm.