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
  • entity is the entity the tween is about to animate. Its current values for every key in props are captured as the starting values — so call make-tween after you've built the entity, not before.
  • props must 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.
  • ease accepts either a symbol from the ease table or a raw procedure of signature (number -> number) mapping \([0,1] \to [0,1]\).
  • repeat counts additional cycles after the first. repeat: 2 plays three times total.
  • yoyo? only matters when repeat is 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:

  1. Looks at #:tween on the entity. If it's #f (or absent), it returns the entity untouched.
  2. Advances the tween by dt milliseconds. If the tween is still in its delay window, nothing interpolates yet.
  3. For each (key . target) pair in props, linearly interpolates between the start and target using the eased progress factor, and writes the result back with entity-set.
  4. When the tween completes (progress reaches 1.0):
    • If repeat is 0, invokes the on-complete callback (once) with the final entity, and returns an entity with #:tween cleared to #f.
    • Otherwise, it decrements repeat and starts a new cycle. If yoyo? is true, it swaps starts and ends so the next cycle plays backward.

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 #:x and 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-game and 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.

Author: Gene Pasquet

Created: 2026-04-20 Mon 15:22

Validate