Tweens
Table of Contents
Tweens smoothly interpolate numeric entity properties over time: an
#:x that slides, a #:y that bobs, a custom numeric field that ramps
up for use inside your update: hook. You build a tween with
make-tween, attach it to an entity under the #:tween key, and the
engine advances it for you every frame. When it finishes, it removes
itself.
This doc covers the (downstroke tween) module: make-tween, the
step-tweens pipeline step, and every easing curve shipped in the
*ease-table*.
1. The minimum you need
A one-shot slide to the right, 500 ms, linear ease:
(import (downstroke tween))
(let ((e (entity-set (make-entity 0 100 16 16) #:type 'slider)))
(entity-set e #:tween
(make-tween e props: '((#:x . 300)))))
make-tween reads current values off the entity you pass in (starts),
stores your props as the targets (ends), and returns an opaque tween
struct. Put that struct on the entity under #:tween and the engine's
default update pipeline will advance it — no further wiring.
No entity key? No problem. make-tween treats any missing source key
as 0 (see entity-ref in the source), so tweening a custom numeric
key that isn't on the entity yet starts from zero.
2. Core concepts
2.1. Creating a tween
make-tween signature (from tween.scm):
(make-tween entity #!key
props ; alist of (#:key . target-number) — REQUIRED
(duration 500) ; ms, must be a positive integer
(delay 0) ; ms before interpolation starts
(ease 'linear) ; symbol (see ease table) or a procedure
(on-complete #f) ; (lambda (entity) ...) called once at end
(repeat 0) ; 0 = no repeats, -1 = infinite, N = N more cycles
(yoyo? #f)) ; swap starts/ends on every repeat
entityis the entity the tween is about to animate. Its current values for every key inpropsare captured as the starting values — so callmake-tweenafter you've built the entity, not before.propsmust be a non-empty alist whose keys are keywords:((#:x . 200) (#:y . 40)). Any numeric entity key is valid — the standard#:x/#:y/#:width/#:height, a physics field like#:vx, or a custom key you inspect inside your own hooks.easeaccepts either a symbol from the ease table or a raw procedure of signature(number -> number)mapping \([0,1] \to [0,1]\).repeatcounts additional cycles after the first.repeat: 2plays three times total.yoyo?only matters whenrepeatis non-zero. It flips starts and ends on each cycle so the tween plays forward, backward, forward, etc.
A full call:
(make-tween player props: '((#:x . 200) (#:y . 40)) duration: 800 delay: 100 ease: 'cubic-in-out on-complete: (lambda (e) (print "arrived")) repeat: 0 yoyo?: #f)
Violating the contracts — duration not a positive integer, props not a
non-empty alist, a non-keyword key, a bad repeat value — raises an
error immediately from make-tween.
2.2. The tween lifecycle
Attach a tween by storing it under #:tween on an entity. The engine's
default update pipeline runs step-tweens as its first per-entity
step (see engine.scm, default-engine-update):
(chain scene (scene-map-entities _ (cut step-tweens <> <> dt)) (scene-map-entities _ (cut apply-acceleration <> <> dt)) ;; ... )
step-tweens is a define-pipeline step with the pipeline name
tweens. On each frame it:
- Looks at
#:tweenon the entity. If it's#f(or absent), it returns the entity untouched. - Advances the tween by
dtmilliseconds. If the tween is still in itsdelaywindow, nothing interpolates yet. - For each
(key . target)pair inprops, linearly interpolates between the start and target using the eased progress factor, and writes the result back withentity-set. - When the tween completes (progress reaches 1.0):
- If
repeatis 0, invokes theon-completecallback (once) with the final entity, and returns an entity with#:tweencleared to#f. - Otherwise, it decrements
repeatand starts a new cycle. Ifyoyo?is true, it swaps starts and ends so the next cycle plays backward.
- If
The "clear to #f" behaviour is hard-coded in step-tweens (see
tween.scm):
(if (tween-finished? tw2)
(entity-set ent2 #:tween #f)
(entity-set ent2 #:tween tw2))
So you never have to clean up finished tweens — just check whether
#:tween is #f if you need to know whether one's running.
Note: on-complete fires only when the tween truly ends (repeat
exhausted), not on every cycle of a repeating tween. For an infinite
tween (repeat: -1), on-complete never fires.
2.3. Easing functions
The ease symbol you pass to make-tween is looked up in *ease-table*
and resolved to a procedure. The full table, copied from
tween.scm:
| symbol | shape |
|---|---|
linear |
straight line, no easing |
quad-in |
slow start, quadratic |
quad-out |
slow end, quadratic |
quad-in-out |
slow start and end, quadratic |
cubic-in |
slow start, cubic (sharper than quad) |
cubic-out |
slow end, cubic |
cubic-in-out |
slow start and end, cubic |
sine-in-out |
smooth half-cosine |
expo-in |
slow start, exponential |
expo-out |
slow end, exponential |
expo-in-out |
slow start and end, exponential |
back-out |
overshoots past the target then settles |
Passing an unknown symbol raises ease-named: unknown ease symbol
immediately from make-tween.
You can also pass a procedure directly as ease:. ease-resolve
accepts any procedure of signature (number -> number) mapping the
normalised time \(t \in [0,1]\) to an eased factor. A trivial example:
;; A "step" ease: snap halfway through. (make-tween e props: '((#:x . 100)) duration: 400 ease: (lambda (t) (if (< t 0.5) 0.0 1.0)))
The returned factor is used for a straight linear interpolation
between the start and target, so values outside [0,1] are legal and
will overshoot or undershoot (that's exactly how back-out works).
2.4. Interaction with the pipeline
step-tweens runs as a define-pipeline step named tweens. You can
opt out per-entity by adding the step name to #:skip-pipelines:
(entity-set entity #:skip-pipelines '(tweens))
While that's set, any #:tween on the entity is frozen. Remove the
symbol from the list to resume.
Because step-tweens is the first step in default-engine-update,
a tween that writes to #:x or #:y runs before physics. That
matters in practice:
- If you tween an entity's
#:xand the physics tile-collision step decides to snap the entity back out of a wall that same frame, the tween's write is overwritten for the frame. This is a real scenario — see physics.org for the full pipeline order. - For purely-visual tweens (decorative entities, menu widgets, the demo
boxes in
bin/demo-tweens) you usually also want#:gravity?and#:solid?off so physics leaves the entity alone.
If you need a tween to "win" against physics, either disable the
physics steps you don't want on that entity via #:skip-pipelines, or
set #:gravity? and #:solid? to #f so the physics steps are no-ops.
3. Common patterns
3.1. One-shot move-to-position
Slide the camera-locked player to a checkpoint, then print a message:
(entity-set player #:tween
(make-tween player
props: '((#:x . 640) (#:y . 200))
duration: 600
ease: 'cubic-in-out
on-complete: (lambda (e)
(print "checkpoint reached"))))
on-complete receives the final entity (with props written to
their targets). The engine takes care of clearing #:tween to #f
afterwards.
3.2. Yoyoing bob animation
An enemy that hovers up and down forever:
(entity-set bat #:tween
(make-tween bat
props: `((#:y . ,(+ (entity-ref bat #:y 0) 8)))
duration: 500
ease: 'sine-in-out
repeat: -1
yoyo?: #t))
This is exactly the pattern the tweens demo uses
(+ease-duration+ = 2600 ms, repeat: -1, yoyo?: #t — one entity
per ease, all bouncing in parallel). Because repeat is -1, the
tween never finishes and #:tween stays set for the lifetime of the
entity.
3.3. Chaining actions with on-complete
The callback is invoked with the entity in its final interpolated
state. The signature is just (lambda (entity) ...) and — importantly —
its return value is discarded. Look at tween-complete in
tween.scm: the callback runs for side-effects
only, and the entity returned to the pipeline is the unmodified
final. So on-complete is for triggering world-level actions
(playing a sound, logging, enqueueing a command, mutating a global
flag), not for returning a new version of the entity.
A "slide off screen, then log" example:
(entity-set popup #:tween
(make-tween popup
props: '((#:y . -40))
duration: 400
ease: 'quad-out
on-complete: (lambda (final)
(print "popup dismissed: " (entity-ref final #:id #f)))))
on-complete fires once, only when repeat is exhausted — for a
repeat: -1 tween it never fires.
To chain a new tween after this one, start it from the user update:
hook by inspecting whether #:tween is #f on the entity (remember,
step-tweens clears it automatically on completion). That keeps the
chain inside the normal pipeline data flow instead of trying to
smuggle a new tween through the discarded-callback-return.
3.4. Easing preview recipe
The easing curves are easiest to compare side-by-side. bin/demo-tweens
renders one horizontal-sliding box per ease symbol, labelled with its
name. If you want to eyeball a single curve, copy the per-entity
recipe from the demo:
;; One entity, one ease. Ping-pongs forever between x=20 and x=140.
(define (make-ease-entity ease-sym y rgb)
(let* ((left 20)
(right (+ left 120))
(base (plist->alist (list #:x left #:y y))))
(plist->alist
(list #:type 'tween-demo #:x left #:y y
#:width 14 #:height 14
#:vx 0 #:vy 0 #:gravity? #f #:solid? #f
#:color rgb
#:ease-name ease-sym
#:tween (make-tween base props: `((#:x . ,right))
duration: 2600
ease: ease-sym
repeat: -1 yoyo?: #t)))))
See the full source — including the rendering loop and the label
layout — in demo/tweens.scm. The demo is the canonical visual
reference for every ease name.
Tweens demo — run with bin/demo-tweens, source in demo/tweens.scm.
4. See also
- guide.org — getting started with
make-gameand the main loop. - entities.org — the entity alist model,
entity-ref,entity-set, and#:skip-pipelines. - physics.org — the rest of the default update
pipeline that runs right after
step-tweens. - animation.org — the complementary sprite-frame animation system; frequently paired with tweens on the same entity.