Lissajous orbit

Two sines at right angles, three beats to two. natiny.render.line draws all of it: the track once, then sixty segments chasing the head.

The ribbon is those same lines, thinner and fainter the further back they sit - straight alpha lays them into a taper. The thin guides crossing at the head are the two sines themselves.


    
main.lua
local natiny = require("natiny")

local BG = natiny.color.from_hex("#151E2C")
local OBJECT = natiny.color.from_hex("#A9B3C5")
local ACCENT = natiny.color.from_hex("#F8F9FD")
local OBJECT_TRACK = natiny.color.from_hex("#A9B3C5CC")
local OBJECT_GUIDE = natiny.color.from_hex("#A9B3C5E6")
local OBJECT_RING = natiny.color.from_hex("#A9B3C559")

local function color(c)
    natiny.render.set_color(c)
end

local function with_alpha(c, a)
    return c - c % 0x100 + math.floor(math.max(0, math.min(1, a)) * 255 + 0.5)
end

-- This one color genuinely changes every segment, so RGBA is the useful
-- constructor here; the fixed palette above stays readable hexadecimal.
local function ribbon_color(k)
    return natiny.color.from_rgba(
        (169 + (248 - 169) * k) / 255,
        (179 + (249 - 179) * k) / 255,
        (197 + (253 - 197) * k) / 255,
        k * k)
end

natiny.backend.init()

local win = natiny.window.create("Lissajous orbit", 900, 600)
natiny.window.set_scale_mode(win, natiny.window.SCALE_MODE_STRETCH)

-- The two frequencies.  Whole numbers close the curve; 3:2 is the classic
-- knot, and 5:4 or 7:4 are worth a try.
local FREQ_X, FREQ_Y = 3, 2

local cx, cy = 450, 300
local rx, ry = 330, 210

local function point(u)
    return cx + math.sin(FREQ_X * u) * rx,
           cy + math.sin(FREQ_Y * u) * ry
end

local t = 0.0

natiny.backend.loop(function(dt)
    natiny.window.bind(win, function()
        t = t + dt
        local u = t * 0.9

        natiny.render.clear(BG)

        -- ── The track: the whole closed curve, once ─────────────────
        color(OBJECT_TRACK)
        local px, py = point(0)
        for i = 1, 180 do
            local qx, qy = point(i / 180 * math.pi * 2)
            natiny.render.line(px, py, qx, qy, 1)
            px, py = qx, qy
        end

        -- ── The ribbon: sixty segments behind the head ──────────────
        local N = 60
        for i = N, 1, -1 do
            local k = 1 - i / N                 -- 0 at the tail, 1 at the head
            local ax, ay = point(u - i * 0.016)
            local bx, by = point(u - (i - 1) * 0.016)
            color(ribbon_color(k))
            natiny.render.line(ax, ay, bx, by, 1 + 7 * k)
        end

        -- a dot every few samples, so the ribbon keeps a beat
        for i = 0, 8 do
            local k = 1 - i / 9
            local dx, dy = point(u - i * 0.10)
            color(with_alpha(ACCENT, k * 0.7))
            natiny.render.circle(dx, dy, 3 + 5 * k)
        end

        -- ── The head, and the two sines it is made of ───────────────
        local hx, hy = point(u)

        color(OBJECT_GUIDE)
        natiny.render.line(hx, 60, hx, 540, 1)
        natiny.render.line(60, hy, 840, hy, 1)

        color(OBJECT)
        natiny.render.circle(hx, hy, 8)

        -- a ring breathing out of it, fading as it grows
        local pulse = (t * 1.6) % 1.0
        color(with_alpha(ACCENT, 1.0 - pulse))
        natiny.render.circle_outline(hx, hy, 10 + pulse * 34, 3)

        -- ── A second body, half a turn behind on the same track ─────
        local sx, sy = point(u + math.pi)
        color(OBJECT)
        natiny.render.circle(sx, sy, 6)
        color(OBJECT_RING)
        natiny.render.circle_outline(sx, sy, 13, 3)
    end)
end)

natiny.backend.shutdown()