1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
|
#+TITLE: Tweens
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*=.
* The minimum you need
A one-shot slide to the right, 500 ms, linear ease:
#+BEGIN_SRC scheme
(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)))))
#+END_SRC
=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.
* Core concepts
** Creating a tween
=make-tween= signature (from [[file:../tween.scm][tween.scm]]):
#+BEGIN_SRC scheme
(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
#+END_SRC
- =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:
#+BEGIN_SRC scheme
(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)
#+END_SRC
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=.
** 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 [[file:../engine.scm][engine.scm]], =default-engine-update=):
#+BEGIN_SRC scheme
(chain scene
(scene-map-entities _ (cut step-tweens <> <> dt))
(scene-map-entities _ (cut apply-acceleration <> <> dt))
;; ...
)
#+END_SRC
=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
[[file:../tween.scm][tween.scm]]):
#+BEGIN_SRC scheme
(if (tween-finished? tw2)
(entity-set ent2 #:tween #f)
(entity-set ent2 #:tween tw2))
#+END_SRC
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.
** 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
[[file:../tween.scm][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:
#+BEGIN_SRC scheme
;; 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)))
#+END_SRC
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).
** 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=:
#+BEGIN_SRC scheme
(entity-set entity #:skip-pipelines '(tweens))
#+END_SRC
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 [[file:physics.org][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.
* Common patterns
** One-shot move-to-position
Slide the camera-locked player to a checkpoint, then print a message:
#+BEGIN_SRC scheme
(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"))))
#+END_SRC
=on-complete= receives the /final/ entity (with =props= written to
their targets). The engine takes care of clearing =#:tween= to =#f=
afterwards.
** Yoyoing bob animation
An enemy that hovers up and down forever:
#+BEGIN_SRC scheme
(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))
#+END_SRC
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.
** 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
[[file:../tween.scm][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:
#+BEGIN_SRC scheme
(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)))))
#+END_SRC
=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.
** 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:
#+BEGIN_SRC scheme
;; 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)))))
#+END_SRC
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=.
* See also
- [[file:guide.org][guide.org]] — getting started with =make-game= and
the main loop.
- [[file:entities.org][entities.org]] — the entity alist model,
=entity-ref=, =entity-set=, and =#:skip-pipelines=.
- [[file:physics.org][physics.org]] — the rest of the default update
pipeline that runs right after =step-tweens=.
- [[file:animation.org][animation.org]] — the complementary sprite-frame
animation system; frequently paired with tweens on the same entity.
|