Close
0%
0%

Self-synchronising LED badges using ESP-NOW

Light Festival Badges automatically sync their effects with ESP-NOW. No pairing, No controller, No WiFi

Similar projects worth following
CrowdClock is a self-synchronising LED badge system where devices don’t pair, don’t negotiate, and don’t follow a master—they simply agree on time. Each badge broadcasts a quantised clock value over ESP-NOW and adopts any value higher than local time that it receives. With nothing more than this rule, groups of badges spontaneously fall into sync when they come within range. No topology. No IDs. No state sharing. Just time. Built with young people at Inclusive Bytes for Oldham Illuminate Festival, the project combines distributed systems with hands-on learning—where the crowd doesn’t just watch the display, it becomes part of it. Get the files on github: https://github.com/Inclusive-Bytes/CrowdClock/tree/main/Hardware

Badges that sync themselves—no pairing, no network, no master. They don't synch animations, they synch time.

Using ESP32, ESP-NOW and WS2812 LEDs I created a festival light badge that automatically synchronises effects with its peers without configuration, pairing or external control. Each badge can leave or join a peer group without issue.

I got some the the young people at the none profit I volunteer at to get involved soldering, assembling, coding and wearing the badges.


My town, Oldham (UK) has an annual festival of Light: Illuminate.
I volunteer as a tech mentor at a local none profit tech hub, Inclusive Bytes. And this year we decided
to participate.

Our design is simple. A wearable badge that sychronises lighting effects. I used an ESP32 C3 combined with a LiPo BMS and a WS2812 LED ring. Plus of course a 3D printed badge mount.

I designed a schematic and PCB in Design Spark 


and CNC routed the PCB


The intention was to make about 10 of these, but I knocked a couple out initially so I could get the software working.

A quick check of the hardware showed the current varying between 100-500mA. With a 1000mAh lipo battery that's about 2 hours worst case, which is fine for this application.

Once happy with the design, we got some of the young people at Inclusive Bytes to get some soldering practice in to make the remaining badges.

Once the badges were assembled it was time to start writing some lighting effects....but how does it all work?

To synchronise lighting effects across multiple nodes, you'd normally need a controller of some kind:

  • A boss node, set in software or with a hardware switch
  • A controller on the network
  • Bluetooth pairing
  • WiFi network

None of these are used. Any badge can join or leave a badge group at any time and the effects will still run.

The badges don't synchronise effects.....they synchronise time.

Each badge transmits it millis() value every 5ms or so over ESP-now. It quantises this time to 10ms to help prevent jitter.

transmittedTime = millis() - (millis() % 10)

As another badge receives the new value, it compares it against it's own millis() value and adopts the new time if it is greater.

Effectively, the node that has been running longest since it's last reset becomes the leader in that group.

All badges run the same software, and as time become synched across the group, the badges use their local time to calculate the effect and the effect state.

Time is further quantised to 50ms to further reduce timing errors.

effectTimeSlot = 5000  // Each effect runs for 5s

effectTime =  ( millis() - (millis() % 50 ))

currentEffect = (effectTime / effectTimeSlot)  % NumberOfEffects

effectStage =  effectTime % numberOfStages // There is a time prescaler in here too, omitted for clarity




The codes all in C++. It includes an IEffect interface class for all effects to inherit from:

class IEffect
{
public:
    virtual void Run(CRGB* strip, uint64_t systime) = 0;
};

And an effect manager that allows effects to be registered and selects the current effect.

class EffectManager
{
public:
    void AddEffect(IEffect* effect);
    void Run(CRGB* strip, uint64_t systime);
};



This topography means that any badge can leave or enter a group at any time. Once it has received the highest time vale, it is synched with the other badges. As the time propogates across a mesh, badges that cannot 'see' the original highest time badge just get theirs from their nearby peers.

The daya is transmitted in JSON format, I did this because it's easier for our young developers to see the data and the overhead is minimal. And the JSON gives very basic error checking.

{ "timer": 123456 }

In the real world I'd pack it in a struct with a CRC.

struct
{

   uint64_t timeValue;
   uint16_t  crc

}TimePacket;

There are a few disadvantages using this method:

  • There is no security. Any rogue ESP-NOW could join the group and send receive data.
  • I avoided fades, any small differences in time...
Read more »

  • How do the badges synchronise?

    Tony Goacher05/03/2026 at 18:26 0 comments

    The badges don't really synchronise, they converge on time.

    At first glance these badges look synchronised but there’s no master and no pairing.

    And yet the animations line up.

    It works because they don't actually synchronise time, they converge on it.

    The Rule

    Each badge keeps its own local time:

    t = millis() + offset

    …and periodically broadcasts it over ESP-NOW, quantised to 10ms.

    When a badge hears another:

    if (t_rx > t_local)   
        set offset so that millis() + offset matches new time

    That’s the entire “protocol”.

    There is no averaging, no correction loops. And of particular importance, the local time never goes backwards.

    Inevitably, one badges millis() value will be the highest in a group. When its broadcasts it time to other badges, they adjust their time offset so their local timestamp matches the new value.

    The new time is then rebroadcast. So the highest time propogates through the group. Even if not all badges are directly in range, the update spreads hop-by-hop until everyone catches up.

    Normally this kind of thing would oscillate or fight itself.

    Two things stop that:

    1. Time only moves forward No backwards correction = no ping-ponging
    2. Time is quantised Small differences collapse into the same value

    So instead of chasing each other endlessly, nodes quickly “snap” to the same tick. Inevitably there will always be small differences in each badges time, but this is handled with another quantisation.

    It looks perfect because the LED effect rendering is stateless.

    Rendering  uses 50ms quantisation of the effet time to further absorb any timing differences:

    MILLIS_PER_EFFECT = 5000 // 5 seconds for each effect
    
    effectTime = t - (t % 50)
    effectId = (effectTime / MILLIS_PER_EFFECT)  % numEffects
    effectStage = t % numEffectStages

    So as soon as two badges agree on time, they produce identical output.

    There’s no history to repair, no frames to catch up — just the current tick.

    As a result, nodes can join (or leaver) the group at any time. They instantly sync to the effect state determined by 'global' time.
    Packet loss is irrelevant. Each badge runs off it's local millis() which it updates peridodically from the broadcast time, and because there is no central coordinator, the effects just continue even if comms fail (though they will drift out of sync until a new broadcast is received)

    What looks like tight synchronisation is really just:

    a network agreeing on the largest timestamp, over and over again

    In conclusion, 
    If you're willing to sacrifice time resolution, perfect time syn isn't needed.

    It’s enough to let time “spread” through the system, and design everything else so small errors disappear.

    And in practice that ends up looking indistinguishable from perfect sync.

  • The 50ms Trick: Quantisation for Mesh-Synchronised Effects

    Tony Goacher04/30/2026 at 07:12 0 comments

    One of the more subtle problems in the badge mesh isn't  communication — it's agreement.

    Each badge runs its own local clock and steps through lighting effects based solely on that value. ESP-NOW gives us low-latency broadcast, but it doesn’t give us a shared notion of time. Even tiny timing differences (a few milliseconds) quickly turn into visible phase drift in animations.

    The Problem

    If every node advances its animation based purely on millis(), then:

    • Two badges starting “together” won’t stay together
    • Packet latency introduces small offsets
    • Clock drift accumulates over time

    The result: effects that should look synchronised.....don't.

    Don’t Fight Time — Quantise It.

    Instead of trying to perfectly synchronise clocks (hard), I switched to quantising time into fixed slices.

    All animation timing snaps to a global step size:

    50 ms per tick (20 Hz)

    Each node converts its local time into a shared “tick number”:

    tick = millis() - (millis() % 50)

    Now the key idea: Nodes don’t need identical clocks — they just need to agree on the same tick.

    Why 50 ms?

    It’s a sweet spot:

    • Fast enough for reasonable animation speeds (20Hz)
    • Slow enough to tolerate packet jitter
    • Divides nicely into common effect durations

    I experimented with smaller steps (10 ms), but network jitter started to dominate. Larger steps  made animations visibly steppy.

    Mesh Alignment

    When a badge receives a packet, it doesn’t try to syncit's millis() value. Instead it just adds a simple offset:

    alignedMillis = (millis() + offset)

    Incoming messages carry the sender’s tick. The receiver nudges its offset to match.

    Because everything is quantised:

    • Small timing errors collapse into the same tick
    • Nodes naturally “snap” back into alignment
    • No complex clock sync required

    The Effect

    This one change made a huge visual difference. Each badge calulates it's current effect from local aligned time.

    effect = alignedTime % numEffects;

     And each effect calculates it's current state in a similar fashion.

    Before:

    • Effects drift apart
    • Synchronisation feels “loose”

    After:

    • Effects lock together cleanly after a less than a few seconds.
    • The system feels intentional and coordinated

    Takeaway

    Perfect synchronisation is expensive.

    But quantised synchronisation is cheap, robust, and good enough to look perfect.

    Sometimes the trick isn’t making time continuous — it’s making everyone agree on the same discrete version of it.

  • Subtle synch fault due to ESP mounting choice.

    Tony Goacher04/28/2026 at 13:49 0 comments

    A Strange ESP32 RF Bug (and why sockets had to go)

    During early builds, I mounted the ESP32-C3 Mini modules using standard 0.1" female headers so they could be easily replaced.

    This caused a wierd issue.

    ESP-NOW communication was unreliable—some badges would transmit, but often fail to receive broadcasts from others. At first this looked like a software issue, but everything checked out.

    Then something odd happened.

    If I lifted one side of the ESP32 slightly out of the socket… it started working perfectly.

    Plug it back in it was  broken,  lift one side it works

    None of the pins on that side were even used.

    What was going on?

    At 2.4 GHz, small physical details matter more than expected. The most likely causes are:

    • Slight ground impedance differences across multiple pins
    • Parasitic capacitance introduced by the socket
    • Subtle detuning of the onboard antenna due to nearby geometry

    In other words, the connector itself was interfering with RF performance.

    The fix

    For the production badges, I removed the sockets entirely and soldered the ESP32 modules directly to the PCB.

    This completely resolved the issue:

    • Reliable ESP-NOW reception
    • Stable synchronisation across groups

    Takeaway

    Sometimes the most subtle problems in a system aren’t in the code or the protocol—but in a few millimetres of hardware.

    At RF frequencies, “it shouldn’t matter” often means “it definitely matters”.

View all 3 project logs

Enjoy this project?

Share

Discussions

Similar Projects

Does this project spark your interest?

Become a member to follow this project and never miss any updates