Advanced Animation Patterns: Comet, Scanner, and Candle
Summary
Teaches the most complex animation effects—heartbeat, Larson scanner, comet tail, dual-direction scanning, candle flicker, photo-reactive animations, sensor-driven displays, and clock with localtime()—using brightness envelopes, scanner width parameters, and comet trail length control.
Concepts Covered
This chapter covers the following 17 concepts from the learning graph:
- Heartbeat Pattern
- Comet Tail Animation
- Larson Scanner Pattern
- Comet Trail Length
- Scanner Width Parameter
- Pattern Composition
- Brightness Envelope
- Clock with localtime()
- Seconds and Minutes Display
- Color Mapping to Time
- LED as Clock Hands
- Single-Color Scan
- Dual-Direction Scan
- Alternating Pixel Groups
- Expanding Ripple Rings
- Brightness Formula
- Sine-Based Breathing Effect
Prerequisites
This chapter builds on concepts from:
- Chapter 1: Introduction and Computational Thinking Foundations
- Chapter 2: Python Basics: Variables, Data Types, and Operators
- Chapter 3: Python Functions, Modules, and Programming Best Practices
- Chapter 4: Python Control Flow, Loops, and Error Handling
- Chapter 6: MicroPython APIs, GPIO Control, and Electrical Fundamentals
- Chapter 9: NeoPixel Programming: Pixels, Colors, and the NeoPixel Library
- Chapter 11: Mathematics for LED Programming
- Chapter 12: Basic LED Animation Patterns
- Chapter 13: Intermediate Animation Techniques
Pixel says...
Welcome to Chapter 14! These are the advanced patterns — the ones people remember. A Larson scanner on a costume gets noticed. A comet tail on a hat gets questions. A heartbeat on a glowing sign gets feelings. Let's build the effects that make people look twice. Let's light this up!
What You'll Learn
By the end of this chapter, you'll be able to:
- Implement the heartbeat pattern using a brightness envelope
- Build a comet tail animation with controllable trail length
- Create the Larson scanner (Knight Rider effect) with scanner width control
- Implement dual-direction scanning with two independent scan points
- Use
localtime()to map time to colors and pixel positions - Compose multiple simple patterns into more complex visual effects
What You'll Need
- Raspberry Pi Pico with NeoPixel strip connected
- Thonny IDE connected
import math(available in MicroPython)
The Brightness Envelope Concept
Before building the advanced patterns, let's define brightness envelope — the concept underlying all of them.
A brightness envelope is a function that maps time (or position, or distance) to a brightness value. Instead of pixels being simply "on" or "off," each pixel has a brightness determined by where it falls in a mathematical envelope.
The table below summarizes the envelopes used in this chapter:
| Pattern | Envelope Shape | Key Variable |
|---|---|---|
| Heartbeat | Double pulse then long pause | Phase counter |
| Comet tail | Exponential decay behind head | Trail length |
| Larson scanner | Gaussian peak at scan position | Scanner width |
| Sine breathing | Smooth sine wave | Time step |
Understanding the envelope is 80% of understanding the pattern. The code is just implementing the envelope mathematically.
Pattern: Heartbeat
The Heartbeat Pattern simulates a pulse: two quick bright flashes (the "lub-dub" of a heartbeat) followed by a longer dark pause.
Before the code, here's the brightness envelope: - Phase 0: ramp up fast (bright flash 1) - Phase 1: ramp down fast - Phase 2: ramp up fast (bright flash 2, slightly smaller) - Phase 3: ramp down fast - Phase 4: long pause at darkness
import math, utime
def heartbeat_value(phase, total_phases):
# Returns brightness 0-255 for a given phase position
if phase < total_phases * 0.1:
return int(phase / (total_phases * 0.1) * 255) # ramp up fast
elif phase < total_phases * 0.2:
return int((1 - (phase - total_phases*0.1) / (total_phases*0.1)) * 255) # ramp down
elif phase < total_phases * 0.3:
return int((phase - total_phases*0.2) / (total_phases*0.1) * 180) # smaller pulse
elif phase < total_phases * 0.4:
return int((1 - (phase - total_phases*0.3) / (total_phases*0.1)) * 180) # ramp down
else:
return 0 # long pause
TOTAL = 60 # total phases per heartbeat cycle
while True:
for phase in range(TOTAL):
brightness = heartbeat_value(phase, TOTAL)
for i in range(config.NUMBER_PIXELS):
strip[i] = (brightness, 0, 0) # red heartbeat
strip.write()
utime.sleep_ms(20)
You should see the strip pulse red in a double-beat heartbeat rhythm — two quick flashes, then a pause, then repeat.
Pattern: Comet Tail Animation
The Comet Tail Animation features a bright head pixel moving across the strip with a decaying brightness trail behind it. The comet trail length controls how many pixels the trail spans.
Before the code:
- trail — a list of brightness values for each pixel
- DECAY — how quickly trail brightness fades (0.8 = drops to 80% each frame)
- TRAIL_LENGTH controls apparent tail length — higher DECAY = longer tail
trail = [0.0] * config.NUMBER_PIXELS
DECAY = 0.8
pos = 0
while True:
# Decay all brightness
for i in range(config.NUMBER_PIXELS):
trail[i] *= DECAY
# Set head to maximum
trail[pos] = 1.0
# Render the trail
for i in range(config.NUMBER_PIXELS):
b = int(trail[i] * 200)
g = int(trail[i] * 80)
strip[i] = (b, g, 0) # orange-yellow comet
strip.write()
pos = (pos + 1) % config.NUMBER_PIXELS
utime.sleep_ms(30)
You should see a bright orange-yellow comet head racing around the strip, leaving a fading warm trail.
To control trail length: change DECAY. At 0.95, the trail persists much longer. At 0.5, it disappears almost instantly after the head passes.
Pixel thinks...
The trail length is not a direct setting — it's an emergent property of the decay rate and animation speed. Want a longer trail? Slow down the animation OR increase the DECAY value. Want a shorter, crisper trail? Speed up or decrease DECAY. Two knobs, one effect.
Pattern: Larson Scanner (Single-Color Scan)
The Larson Scanner (inspired by the KITT car from Knight Rider) is a bright hot-spot that bounces back and forth, with neighboring pixels lit proportionally dimmer based on their distance from the scan center.
Before the code, the brightness formula for each pixel: - Distance from scan center → 0 = full brightness, higher distance = dimmer - The scanner width parameter controls how wide the bright spot appears
import math
scan_pos = 0.0 # position (float for smooth motion)
direction = 1.0
SPEED = 0.5 # pixels per frame
WIDTH = 3 # half-width of scanner glow
while True:
for i in range(config.NUMBER_PIXELS):
distance = abs(i - scan_pos)
if distance < WIDTH:
brightness = int((1 - distance / WIDTH) * 255)
else:
brightness = 0
strip[i] = (brightness, 0, 0) # red scanner
strip.write()
scan_pos += direction * SPEED
if scan_pos >= config.NUMBER_PIXELS - 1:
direction = -1
elif scan_pos <= 0:
direction = 1
utime.sleep_ms(20)
You should see a red glow that sweeps back and forth, with pixels on either side of the center glowing proportionally dimmer.
Increase WIDTH for a wider, softer glow. Decrease it to 1 for a tight, intense spot.
Dual-Direction Scan
The Dual-Direction Scan extends the scanner to two independent scan heads moving in opposite directions. The brightness is the maximum of the two scanners at each pixel.
pos1, pos2 = 0.0, float(config.NUMBER_PIXELS - 1)
dir1, dir2 = 1.0, -1.0
WIDTH = 3
while True:
for i in range(config.NUMBER_PIXELS):
d1 = abs(i - pos1)
d2 = abs(i - pos2)
b1 = max(0, int((1 - d1 / WIDTH) * 200)) if d1 < WIDTH else 0
b2 = max(0, int((1 - d2 / WIDTH) * 200)) if d2 < WIDTH else 0
brightness = max(b1, b2) # take the brighter of the two
strip[i] = (0, 0, brightness) # blue dual scanner
strip.write()
pos1 += dir1 * 0.5
pos2 += dir2 * 0.5
if pos1 >= config.NUMBER_PIXELS - 1 or pos1 <= 0: dir1 *= -1
if pos2 >= config.NUMBER_PIXELS - 1 or pos2 <= 0: dir2 *= -1
utime.sleep_ms(20)
You should see two blue glowing spots racing toward each other, bouncing off the ends and crossing paths in the middle.
Sine-Based Breathing Effect
The Sine-Based Breathing Effect uses a sine wave to smoothly oscillate all pixel brightness. You learned the math in Chapter 11 — here's the full implementation:
import math, utime
t = 0.0
SPEED = 0.05 # how fast the breath cycles
while True:
# Sine gives -1 to 1; shift and scale to 0 to 1
level = (math.sin(t) + 1) / 2
for i in range(config.NUMBER_PIXELS):
r = int(level * 100)
g = int(level * 50)
b = int(level * 200)
strip[i] = (r, g, b) # blue-purple breathing
strip.write()
t += SPEED
utime.sleep_ms(20)
You should see the strip smoothly pulse between dark and a soft blue-purple color.
Diagram: Brightness Envelopes for Advanced Patterns
Run Brightness Envelopes for Advanced Patterns Fullscreen
Interactive chart: compare heartbeat, comet, scanner, and sine envelopes
Type: chart sim-id: brightness-envelope-comparison Library: Chart.js Status: Specified
A tab-panel with four tabs, each showing the brightness envelope for one pattern:
- Heartbeat tab: X axis = time steps (0–60), Y axis = brightness (0–255). Two narrow peaks (at t=6 and t=18) followed by a long flat zero section. Animates a moving dot.
- Comet Tail tab: X axis = pixel index (0–29), Y axis = brightness. Exponential decay from a peak at position 20, falling toward zero to the left. A "head position" slider moves the peak.
- Larson Scanner tab: X axis = pixel index, Y axis = brightness. A triangular peak centered at scan position. A "width" slider changes the peak width.
- Sine Breathing tab: X axis = time, Y axis = brightness. Smooth sine curve, labeled with key points (peak, trough, half-way).
Each tab plays a live animation of the strip simulated below the chart. Canvas: 700 × 400 px. Responds to window resize.
Learning objective: Analyzing — the student can match a brightness envelope shape to the visual effect it produces.
Expanding Ripple Rings
The Expanding Ripple Rings effect sends concentric brightness waves outward from a center point on each pulse trigger. This builds on the Chapter 13 ripple with improved pulse logic.
Before the code:
- rings — a list of (position, brightness) tuples, one per active ring
- Each frame, ring brightness decays and position expands
- New rings appear on a timer
rings = [] # list of (center_offset, brightness)
pulse_timer = 0
CENTER = config.NUMBER_PIXELS // 2
while True:
# Decay and expand existing rings
new_rings = []
for offset, brightness in rings:
if brightness > 5:
new_rings.append((offset + 1, int(brightness * 0.9)))
rings = new_rings
# Add a new ring pulse periodically
pulse_timer += 1
if pulse_timer > 20:
rings.append((0, 200)) # new ring at center, full brightness
pulse_timer = 0
# Render
for i in range(config.NUMBER_PIXELS):
strip[i] = (0, 0, 0)
for offset, brightness in rings:
left = CENTER - offset
right = CENTER + offset
if 0 <= left < config.NUMBER_PIXELS: strip[left] = (0, 0, brightness)
if 0 <= right < config.NUMBER_PIXELS: strip[right] = (0, 0, brightness)
strip.write()
utime.sleep_ms(40)
You should see blue rings appearing at the center and expanding outward, fading as they travel.
Pattern Composition
Pattern composition means combining two or more simpler animations into one complex effect. The key technique: compute the output from each sub-pattern and blend the results.
Here's an example: a scanner pattern overlaid with a slow breathing effect.
import math, utime
scan_pos = 0.0
scan_dir = 0.5
breath_t = 0.0
while True:
breath_level = (math.sin(breath_t) + 1) / 2
for i in range(config.NUMBER_PIXELS):
# Scanner component
distance = abs(i - scan_pos)
scan_b = max(0, int((1 - distance / 3) * 200)) if distance < 3 else 0
# Breathing background
bg_r = int(breath_level * 30)
bg_b = int(breath_level * 60)
strip[i] = (bg_r + scan_b, 0, bg_b)
strip.write()
scan_pos += scan_dir
if scan_pos >= config.NUMBER_PIXELS - 1 or scan_pos <= 0:
scan_dir *= -1
breath_t += 0.03
utime.sleep_ms(20)
You should see a warm scanner gliding over a slow blue-breathing background — two patterns, one program.
Try It Yourself
-
Color heartbeat: Modify the heartbeat to pulse through the color sequence red → orange → yellow across three consecutive heartbeats.
-
Long comet: Set DECAY to 0.97 in the comet tail. How far back does the tail reach? Count the pixels that are visibly lit.
-
Wide scanner: Increase the scanner WIDTH to 8. Compare with WIDTH = 2. Which looks more like a "laser sweep"?
-
Composed effect: Combine the comet tail and twinkle effects. The comet runs normally, but random pixels briefly spark with a different color as the comet passes nearby.
Check Your Understanding
- What is a "brightness envelope"? Give one example.
- In the comet tail, what does the DECAY value control?
- What two parameters control the appearance of the Larson scanner?
- How does dual-direction scanning differ from the single-color scan?
- What does
(math.sin(t) + 1) / 2produce, and why is it useful? - What is "pattern composition"?
- How does the expanding ripple rings effect track each ring's position over time?
Chapter complete!
You've built the showstoppers! The Larson scanner, comet tail, heartbeat, and dual scanner are the effects that get noticed at Halloween, maker fairs, and costume contests. You also learned how to compose patterns together — which means from now on, "pattern + pattern = new pattern" is always an option. Level up!
What's Next
In Chapter 15, you'll step back from code and go deeper into electronics — resistance, Ohm's Law, breadboard wiring, potentiometers, transistors, and the components you'll use in your capstone circuits.