published: February 1, 2023 —
last modified: October 23, 2025
In this blog post I explain the microcontroller firmware that emulates candlelight in more detail. You can apply the explained techniques in similar situations to get organic effects on CPU and RAM-limited platforms. I will focus in my description on emulating a convincing random effects with minimal resource usage.
Table of Contents
Demo
The following video demonstrates the candlelight effect, but cameras cannot capture light situations like this very well.
Characteristics of Candlelight Flickering
If you observe a burning candle over a long time, you will notice a few key characteristics:
The flame burns in a colour range from a deep orange to a bright, almost white yellow.
The candle’s flame is usually small and orange at a low burning rate and gets higher and brighter if it increases.
Over an extended period, the burning rate varies, causing the candle to emit more or less light. Industrially produced candles have a more uniform burn rate than handmade ones.
If a draft passes the flame, it causes rapid flame flickering.
Even in closed rooms, slight air turbulence creates drafts that trigger the flame to flicker randomly.
Light that behaves in a pattern as described will trick your brain into perceiving an open burning flame.
The Hardware
I used a strip of WS2812B RGB LED modules for the hardware that can be individually addressed.
The strip was wound around a cylinder, so the individual LEDs built five columns with four rows.
Downloads
All code examples you see on this page are from the candlelight emulator firmware you can download from GitHub:
My first goal was to create a colour gradient that resembles a candle’s flame shades. The following image is a rough illustration of what I tried to archive.
For simplicity, I used a single byte as a level value and wrote a function that returns the colour for a given level.
/// Calculate the color for a given level of light intensity.
///
/// For the hue, make a transition from red to yellow.
/// For the value, go from 0 to 100%
/// for the saturation, start with 100%, then from 128, drop down to 0%.
///
/// @param level The level of the color from 0-255
/// @return The color value for this level.
///
autocolorForLevel(Levellevel)noexcept->Color{uint16_thue{cHueBase};if(level>64){hue=uint16_t{level}*cHueFactor+cHueBase;}uint8_tsaturation{255};if(level>=uint8_t{128}){saturation=uint8_t{255}-((level-uint8_t{128})/uint8_t{4});}uint8_tvalue{};if(level>=192){value=255;}elseif(level>=64){value=(level-uint8_t{64})*uint8_t{2};}if(value<cValueCutOff){returnstrip.Color(0,0,0);}returnstrip.ColorHSV(hue,saturation,value);}
The function only uses multiplication and a binary-compatible division that the compiler will convert into a bit shift operation.
Create a Display with the Colours
Next, I apply the colours to the individual LEDs. If I set all LEDs to the same colour uniformly, it would create a static-looking display. Therefore, I shifted the colours to make the rows look more dynamic and introduced a tilt value that added variations between the columns.
I started working on the animation by implementing slow changes in the burning rate. Here, I created a class RandomWave that interpolates a value between changing boundaries in random durations.
Random Waves
/// A class that is generating a random wave with a defined timing and value range.
///
classRandomWave{public:/// The value type created for the wave.
///
usingValue=int16_t;/// The value that the `millis()` method is using.
///
usingTimeValue=uint32_t;/// A signed time value, used for the calculation.
///
/// Specified due the lack of <type_traits> in the AVR toolchain.
///
usingSignedTimeValue=int32_t;/// The configuration for the random wave.
///
structConfig{ValueminimumValue;ValuemaximumValue;TimeValueminimumDuration;TimeValuemaximumDuration;};public:/// Create a new random wave.
///
/// @param config The configuration.
///
explicitRandomWave(Configconfig)noexcept;public:/// Initialize the random wave.
///
/// Call this from `setup()` once.
///
/// @param currentTime The current time from the `millis()` function.
///
voidinitialize(TimeValuecurrentTime)noexcept;/// Get the current value of the wave.
///
/// Call this method from the `loop()` method.
///
/// @param currentTime The current time from the `millis()` function.
///
autovalueAt(TimeValuecurrentTime)noexcept->Value;private:/// Initialize the values for a next section of the wave.
///
/// @param currentTime The current time from the `millis()` function.
///
voidnextRandom(TimeValuecurrentTime)noexcept;private:Config_config;///< The configuration.
TimeValue_startTime;///< The start time of the current section.
TimeValue_currentDuration;///< The duration for the current section.
Value_startValue;///< The start value for the current section.
Value_endValue;///< The end value for the current section.
};
The benefit of interpolating values over time is the simplicity of the implementation, making it independent of the speed of the microcontroller.
The function valueAt() only requires the current time in milliseconds. Calculating a delta to the start time can interpolate the value between the start and end values with simple integer mathematics. I use a factor 1000 for the required precision of the calculations; by only using binary-compatible factors, e.g. like 256, you can further reduce the size of the generated code for some platforms.
Animate the Burning Rate
If you look at the code in the Application module, the burning rate is implemented by using an instance of the RandomWave class with the following parameters:
/// A slow random wave, emulating variations in the burn rate.
///
RandomWavegSlowWave{{100,180,3000,6000}};
The output of this function over time looks like this:
It is a very slow shift in the brightness of the display. In the short example above, it looks as the algorithm only produces spikes, but if you look at a longer sample, you see that there are always flat shallow transitions with little change.
Animate Flickering in Draft
I use the same class to emulate the random flickering effect of the flame if the candle is exposed to draft.
/// A fast random wave, emulating flickering in a draft.
///
RandomWavegFastWave{{-40,40,20,120}};
The shorter times with the changed start and end values will generate a fast random change in the display brightness.
As you can see, the gFastWave instance creates a delta value, that is added to the slow changing base value of the candle.
Modulating the Draft
The result would be a candle that is permanently flickering that does not look very realistic. Therefore I added another algorithm to emulate airflow at random intervals. This class is called RandomSupressor as it suppresses the flickering most of the time.
classRandomSuppressor{public:/// The value type created for the wave.
///
usingValue=int16_t;/// The value that the `millis()` method is using.
///
usingTimeValue=uint32_t;/// A signed time value, used for the calculation.
///
/// Specified due the lack of <type_traits> in the AVR toolchain.
///
usingSignedTimeValue=int32_t;/// The configuration for the random wave.
///
structConfig{TimeValueminimumDuration;TimeValuemaximumDuration;};public:/// Create a new random suppressor.
///
/// @param config The configuration.
///
explicitRandomSuppressor(Configconfig)noexcept;public:/// Initialize the random suppressor.
///
/// Call this from `setup()` once.
///
/// @param currentTime The current time from the `millis()` function.
///
voidinitialize(TimeValuecurrentTime)noexcept;/// Get the current value of the wave.
///
/// Call this method from the `loop()` method.
///
/// @param currentTime The current time from the `millis()` function.
///
autovalueAt(TimeValuecurrentTime)noexcept->Value;private:/// Initialize the values for a next section.
///
/// @param currentTime The current time from the `millis()` function.
///
voidnextRandom(TimeValuecurrentTime)noexcept;private:Config_config;///< The configuration.
TimeValue_startTime;///< The start time of the current section.
TimeValue_currentDuration;///< The duration for the current section.
};
The implementation of this class is very similar to RandomWave:
I work with a fixed value range from zero to 1000 that act as a normalization value used to modulate the flickering. An initial symmetric triangle waveform is interpolated between random time intervals. By calculating the cubic result two times, the triangle is transformed in a series of spikes.
/// A peak suppressor, emulating random drafts passing the candle.
///
RandomSuppressorgSuppressor{{3000,10000}};
The result of gSuppressor looks like this:
Combining all Results
When all results are combined, the brightness value for the display looks like this:
I combined all generated values in one diagram so you can see its individual influences on the result.
Now shown is the fourth waveform for the tilt effect. This last one is very subtle and slow moving, adding more variation to the light.
Conclusion
The four individual random interpolated waveforms are simple to implement and the resulting code fits into the 5kb flash memory of the Adafruit Trinket I used for me experiments. With each additional generator, the results gets more organic and looks more natural.
Working with minimalistic generator code like this is not only useful for small microcontrollers, but also an interesting way to write integer based filter effects. If you have questions, missed any information, or wish to provide feedback, add a comment below or send me a message.
I walk you through assembling the gearbox for my 3D-printed cat feeder, step by step. I cover inserting insets, bearings, grub screws, and fitting the NEMA17 motor so the turntable runs smoothly. If you printed the parts from Part 1, follow along — read the full post for photos and testing tips.
I walked through designing and 3D-printing a 70 cm PETG planter for my deep-rooted Ctenanthe. I share the tests, design choices, and practical fixes for warping, filament, split parts, and assembly. If you’re curious about large-scale prints or want the model files and tips, please read the full post.
I'm not a 3D printing guru, but I built and tested three print-in-place hinges optimized for PETG/ASA and explain the small design choices that make them work reliably. I walk you through bridging, fin widths, overhangs, and conical holes — read the full post to learn how to print them well.
I show how I built a refillable replacement for the Philips FY3432 active coal filter using 3D‑printed PLA parts, mesh, M3 insets and epoxy. If you want a lower‑waste workshop air filter and practical assembly steps, read the full guide — I cover materials, print files and tips.
I’ll walk you through my parametric, heavy wood-and-acrylic 3D-printer enclosure — from choosing boards and adjusting the Fusion 360 model to cutting parts, assembly, and 3D-printed hinges. If you want a stable, customizable enclosure for your printer, read the full tutorial for measurements, photos, and tips.
I recently built 'Always-On', a minimal bidirectional mains-to-Feather bridge combining a power sensor and solid-state relay for safe home-control prototypes. I outline design choices, safe firmware testing without mains, and housing tips. If you're exploring a similar controller, read the full post for a practical starting point.