Tweens in Janet

Earlier this month I participated in the 2024 GMTK Game Jam. As usual I made my game in TIC-80 using the Janet programming language. My philosophy for these sorts of games has been to just copy & paste the code I need from my previous games. There have been a couple of common functions, classes, and patterns I've found myself copying over and over. I've already written about one, my 2D vector implementation, so let's talk about another one!

Tweening ... or Lerping ... or Easing

In almost every game you're going to have to solve the problem of transitioning a value from A to B over time. Think of things like...

  1. Sliding part of a UI in or out of view
  2. Having a character, or just part of a character, follow the mouse.
  3. Changing the height of a platform when the player pulls a lever.
  4. Decreasing a health bar, like in Pokemon.

You don't want to just set the new value, since that would happen instantly, most likely look bad, and not be very "juicy". The solution is to use easing functions! If you don't know what easing/tweening, check out this great blog post by Federico Bellucci as it does a better job explaining then I ever could.

Ok, you back? let's implement this in Janet!

Step 1 is to write the "linear interpolation" function which finds a value between a start and end using a tween function. Is common to name this function "lerp" for short. Also note that this function is a little spicy since it handles both numbers and lists of numbers. I did this so it places nicely with my vector code!

(defn lerp [start end percent tween-fn]  
  (if (or (tuple? start) (array? start))
	(map |(+ $0 (* (- $1 $0) (tween-fn percent))) start end)
	(+ start (* (- end start) (tween-fn percent))))) # this is important bit
SIDENOTE: One really cool feature of Janet's map function is that you can give it multiple lists, and it will iterate through all of them in lock step! Check out some more examples here.

What does tween-fn actually look like though? Well it's just a function which takes a number called s between 0 and 1 and returns a new number. How it maps that number is what gives different movements. For example here is the quadratic ease in function.

(defn in-quad [s]
  (* s s))

...not too complicated.

You'll remember that there are also "out" and "in-out" ease functions for quad. We could write out each function 1 by one, but the clever thing to do is to instead use higher order functions which take the "in" function and create the others for us!

This flip function will return the "out" version of any easing function you give it!

(defn flip [ease-in-fn]
  (fn [s & args]
	(- 1 (ease-in-fn (- 1 s) ;args))))

It works by inverting s to go from 1 to 0 instead. Neat!

Similarly we can write a slightly more complicated function which chain's the "in" and "out" functions into the in-out ease. It works by using the "in" function until s is 0.5 (halfway), then switches over to "out"... with some spooky math to make everything line up.

(defn chain [ease-in-fn ease-out-fn]
  (fn [s & args]
	(* 0.5
   	(if (< s 0.5)
 	(ease-in-fn (* 2 s) ;args)
 	(+ 1 (ease-out-fn (- (* 2 s) 1) ;args))))))

With these 2 higher order functions we can define all 3 of our quad tweens like this!

(defn in-quad [s] (* s s))
(def out-quad [s] (flip in-quad))
(def in-out-quad [s] (chain in-quad out-quad))

But we're in Janet, which means we can write a macro which defines all 3 functions for us!

(defmacro- def-tween [name & body]
  (with-syms [$in $out $in-out $out-in]
	(let [$in (symbol "in-" name)
  	$out (symbol "out-" name)
  	$in-out (symbol "in-out-" name)]
  	~(upscope
 	(defn ,$in [s] ,;body)
 	(def ,$out (,flip ,$in))
 	(def ,$in-out (,chain ,$in ,$out))))))

With this macro our entire quad definition becomes one line, just give it the body of the "in" function.

(def-tween quad (* s s))

That's all there is to it. Below is the complete code for easy copy/pasting, with a bunch of common easing functions. I hope you have fun lerping in your next game!

Expand for complete code.
# Simple tweens for janet tic80 games

(defn lerp [start end percent tween-fn]  
  (if (or (tuple? start) (array? start))
	(map |(+ $0 (* (- $1 $0) (tween-fn percent))) start end)
	(+ start (* (- end start) (tween-fn percent)))))

(defn flip [ease-in-fn]
  (fn [s & args]
	(- 1 (ease-in-fn (- 1 s) ;args))))

(defn chain [ease-in-fn ease-out-fn]
  (fn [s & args]
	(* 0.5
   	(if (< s 0.5)
 	(ease-in-fn (* 2 s) ;args)
 	(+ 1 (ease-out-fn (- (* 2 s) 1) ;args))))))

(defmacro def-tween [name & body]
  (with-syms [$in $out $in-out $out-in]
	(let [$in (symbol "in-" name)
  	$out (symbol "out-" name)
  	$in-out (symbol "in-out-" name)]
  	~(upscope
 	(defn ,$in [s] ,;body)
 	(def ,$out (,flip ,$in))
 	(def ,$in-out (,chain ,$in ,$out))))))

(def-tween linear s)
(def-tween quad (* s s))
(def-tween cubic (* s s s))
(def-tween quart (* s s s s))
(def-tween quint (* s s s s s))
(def-tween sine (- 1 (math/cos (* s (/ math/pi 2)))))
(def-tween expo (math/exp2 (* 10 (- s 1))))
(def-tween circ  (- 1 (math/sqrt (- 1 (* s s)))))

# warning: magic numbers ahead
(def-tween back (* s s (- (* s 2.70158) 1.70158)))

(def-tween bounce
  (let [a 7.5625 b (/ 1 2.75)]
	(min (* a (math/pow s 2))
     	(+ 0.75 (* a (math/pow (- s (* b (- 1.5))) 2)))
     	(+ 0.9375 (* a (math/pow (- s (* b (- 2.25))) 2)))
     	(+ 0.984375 (* a (math/pow (- s (* b (- 2.625))) 2))))))

(def-tween elastic
  (let [amp 1 period 0.3]
	(* (- amp)
   	(math/sin (- (* 2 (/ math/pi period) (- s 1)) (math/asin (/ 1 amp))))
   	(math/exp2 (* 10 (dec s))))))