Home News Notes Projects Themes About

2025-01-04 – An analog clock

This project aims at building an analog clock in the TIC-80 fantasy console through iterative development and literate programming. Each iteration will incrementally add new features and refine existing ones, starting with a simple timer-like clock in iteration 1 and evolving from there.

In this project, functions are intentionally kept small and focused, and are designed to operate on tables (or maps).

Iteration 1: analog clock with a second hand

We will begin with a simple analog clock featuring only a second hand. In the code, this clock can be represented in two ways:

Time representation
The number of seconds that have elapsed since the most recent minute. It is an integer in the range [0,60[. We will refer to this representation as a clock in the code.
Geometric representation
The angle in degrees between the second hand and the 12 o'clock position. It is an integer in the range [0,360[. We will refer to this representation as a geometric clock in the code, or geoclock (a kind of geometrical object).

We will start writing the program by converting an arbitrary number of seconds into a clock:

(fn seconds->clock [seconds]
  (% seconds 60))

Converting a clock to a geoclock is equally straightforward:

(fn clock->geoclock [clock]
  (* (/ clock 60) 360))

Next, we need to actually draw an analog clock in the TIC-80. To begin simply, we draw a static hand at the 12 o'clock position. This is done by rendering a 50-pixel line extending upward from the center of the screen:

(fn _G.TIC []
  (cls 8)
  (let [center-x (/ 240 2)
        center-y (/ 136 2)]
    (line center-x center-y center-x (- center-y 50) 12)))

This results in a basic representation of the second hand:

2025-01-04_clock_1.png

The above implementation is functional but lacks abstraction. To address this, we introduce helper functions to organize and generalize the drawing logic:

(fn half [x] (/ x 2))

(fn point [x y] {: x : y })

(fn pline [p1 p2 color]
  (line p1.x p1.y p2.x p2.y color))

(fn draw-hand [center size color]
  (pline center (point center.x (- center.y size)) color))

(local center (point (half 240) (half 136)))

(fn _G.TIC []
  (cls 8)
  (let [white 12]
    (draw-hand center 50 white)))
  1. half – A utility function to compute half of a given number.
  2. point – A helper function to create a point with x and y coordinates.
  3. pline – A utility function that draws a line between two points.
  4. draw-hand – A function that uses pline to draw a hand (a line) extending upward from a given center, with a size and color.

So far, there is no relationship between a clock and its hand. To establish this relationship, we need to allow the draw-hand function to accept an angle. To implement this, we require a little bit of geometry.

The mathematical formula for rotating a point (x, y) around the origin by an angle theta is as follows:

\[ x' = x \cos \theta - y \sin \theta \]

\[ y' = y \cos \theta + x \sin \theta \]

You can test things out on this page. Note that the rotation is counterclockwise because Y values go up in a classic plane. On the TIC-80, values go down, so the rotation will be clockwise (which is a good thing for a clock 🙂).

Using the formula above, we can create a function, rotate-orig, that rotates a given point (x, y) around the origin by a specified angle in degrees, called deg, and returns the resulting point:

(fn rotate-orig [x y angle]
  {:x (- (* x (math.cos angle)) (* y (math.sin angle)))
   :y (+ (* y (math.cos angle)) (* x (math.sin angle)))})

Reworking things a little bit, using our point function and allowing an angle in degrees, we get:

(fn rotate-orig [p deg]
  (let [rad (math.rad deg)
        cos (math.cos rad)
        sin (math.sin rad)]
    (point (- (* p.x cos) (* p.y sin))
           (+ (* p.y cos) (* p.x sin)))))

However, we need a function that rotates a point around any other point, not only the origin of the plane. Here's the formula to do it:

\[ x' = (x - cx) \cos \theta - (y - cy) \sin \theta + cx \]

\[ y' = (y - cy) \cos \theta + (x - cx) \sin \theta + cy \]

Actually, if you look closely, we can keep our original function and write another, more general one:

(fn rotate [p1 p2 deg]
  (let [p1o (point (- p1.x p2.x) (- p1.y p2.y))
        p2o (rotate-orig p1o deg)]
    (point (+ p2o.x p2.x)
           (+ p2o.y p2.y))))

Indeed, the process of rotating a point around any other point can be broken down into three steps:

  1. Translate the system – Move the point p1 such that the center of rotation p2 becomes the origin, to obtain p1o. This is achieved by subtracting the coordinates of p2 from p1.
  2. Rotate around the origin – Use the existing rotate-orig function to perform the rotation of the translated point p1o by the given angle deg, to obtain p2o.
  3. Translate back – Move p2o back to the original coordinate system by adding the coordinates of p2. This reverts the earlier translation, placing the rotated point in its correct position.

Although I could write rotate directly, for now I like having rotate-orig as a helper function because it makes things clearer in my head 🙂

Next, we update the draw-hand function to use this rotation logic. By incorporating an angle parameter, the function can now draw a clock hand at any specified angle:

(fn draw-hand [center size deg color]
  (let [noon (point center.x (- center.y size))
        extremity (rotate noon deg)]
    (pline center extremity color)))
  1. A "noon" point is created with the previous logic.
  2. The rotate function is used to rotate the initial "noon" position by the specified angle deg, resulting in the new endpoint of the hand, extremity.
  3. Finally, the pline function is used to draw the line from the center of the clock to the hand's extremity.

The result:

(fn half [x] (/ x 2))

(fn point [x y] {: x : y })

(fn pline [p1 p2 color]
  (line p1.x p1.y p2.x p2.y color))

(fn rotate-orig [p deg]
  (let [rad (math.rad deg)
        cos (math.cos rad)
        sin (math.sin rad)]
    (point (+ (* p.x cos) (* p.y (- sin)))
           (+ (* p.y cos) (* p.x sin)))))

(fn rotate [p1 p2 deg]
  (let [p1o (point (- p1.x p2.x) (- p1.y p2.y))
        p2o (rotate-orig p1o deg)]
    (point (+ p2o.x p2.x)
           (+ p2o.y p2.y))))

(fn draw-hand [center size deg color]
  (let [noon (point center.x (- center.y size))
        extremity (rotate noon center deg)]
    (pline center extremity color)))

(local center (point (half 240) (half 136)))

(fn _G.TIC []
  (cls 8)
  (let [hand-size 50
        angle 45
        white 12]
    (draw-hand center hand-size angle white)))

2025-01-04_clock_2.png

Using our functions defined at the beginning, we can now easily draw a moving second hand. We first need to get seconds from TIC-80's time function, that returns milliseconds. Since we want a "discrete" second hand and not a smooth second hand, we'll use math.floor:

(fn get-seconds []
  (math.floor (/ (time) 1000)))

Then, writing something like this will draw a hand that moves with each passing second:

(let [geoclock (-> (get-seconds)
                   (seconds->clock seconds)
                   (clock->geoclock))]
  (draw-hand center (- clock-size 10) geoclock white-color))

The -> threading macro takes its first value and splices it into the second form as the first argument. So in this case, the clock is passed to clock->geoclock and we obtain a geoclock. Yay, Lisp!

By changing draw-hand to take the angle as the first argument:

(fn draw-hand [deg center size color]
  (let [noon (point center.x (- center.y size))
        extremity (rotate noon center deg)]
    (pline center extremity color)))

…we can even get a nice little "functional" pipeline that draws our single-hand clock:

(fn _G.TIC []
  (cls 8)
  (circb center.x center.y clock-size white-color)
  (-> (get-seconds)
      (seconds->clock)
      (clock->geoclock)
      (draw-hand center (- clock-size 10) white-color)))

Our TIC function is now a clock-rendering function that depends on time only!

2025-01-04_clock_3.png

The final code for iteration 1 is available in the 2025-01-04_analog_clock_1.fnl file. It could still be improved in quite a few ways, but since we're seeing an abstraction emerge, we might as well focus our efforts on improving it directly in a next iteration.

Iteration 2: analog clock displaying UTC time

In its initial iteration, our clock functioned more like a timer than a true analog clock. It had only one hand and relied solely on the number of seconds that had elapsed since the program started. In this iteration, we will create a true analog clock that utilizes UNIX time.

So we won't be using the get-seconds function anymore:

(fn get-seconds []
  (math.floor (/ (time) 1000)))

Instead, we will rely on the utime function, which is based on TIC-80's tstamp function:

(fn utime []
  (math.floor (tstamp)))

Our earlier choice to represent a clock as the number of seconds elapsed since "midnight" turns out to be quite fitting, as UNIX time is defined as the number of seconds elapsed since 1970-01-01 at midnight UTC. Leap seconds might pose an issue (I'm not entirely sure at the moment), but for the sake of simplicity, we will ignore them.

However, our analog clock can now display 12 hours, or 43,200 seconds, with its 3 hands. As a result, seconds->clock is updated to:

(fn seconds->clock [seconds]
  (% seconds 43200))

In other words, our previous time representation (or clock) from iteration 1 is now an integer in the range [0,43200[.

Of course, our geometric representation (or geoclock) will also change. We still require an angle in degrees between a hand and the 12 o'clock position, but now this applies to 3 hands: the hour, minute, and second hands.

Following this logic, clock->geoclock becomes:

(fn clock->geoclock [clock]
  (let [h (/ clock 3600)
        m (% (/ clock 60) 60)
        s (% clock 60)]
    {:hdeg (* (/ h 12) 360)
     :mdeg (* (/ m 60) 360)
     :sdeg (* (/ s 60) 360)}))

Finally, we can create a draw-clock function to render the clock with its 3 hands based on a geoclock:

(fn draw-clock [geoclock center size]
  (let [white 12 light-grey 13]
    (circb center.x center.y size white)
    (draw-hand geoclock.hdeg center (- size 20) white)
    (draw-hand geoclock.mdeg center (- size 10) white)
    (draw-hand geoclock.sdeg center (- size 10) light-grey)))

Note that the first argument to draw-clock is a geoclock, allowing us to write a streamlined "pipeline" in the TIC-80's main function:

(fn _G.TIC []
  (cls 8)
  (-> (utime)
      (seconds->clock)
      (clock->geoclock)
      (draw-clock center clock-size)))

And the final result:

2025-01-04_clock_4.png

The final code for iteration 2 is available in the 2025-01-04_analog_clock_2.fnl file.

Iteration 3

Coming soon!