This build was inspired by the flickering candelabras in the haunted house attraction at Disneyland. It's an interesting effect, but I thought I could do better. Unlike most lamp flickering designs, this design features a fully programmable table driven dimming sequence. This revised version supports sixteen brightness levels (the previous had only eight). It can optionally suport multiple dimming channels. The final product is used every Halloween, wired into my porch lights.

The engine for this circuit is an Arduino Uno. It is used to drive an optically isolated triac dimming circuit. All of the phase shift timing is done in software.

You will have to build some interface circuitry. You need two circuits (see schematic below):

The zero crossing circuit takes the AC power line signal and applies it to the LED diodes of an dual-diode opto-coupler. Resistor R1 limits the peak LED current. The AC voltage keep the LEDs lit except at waveform zero crossing. The output of the opto-coupler keeps the output signal low except at zero crossing time. Resistor R2 provides the pull-up voltage for the output signal. This signal feeds the input of the Arduino.

The lamp driver circuit is a basic phase modulated dimmer circuit. A pulse signal from the Arduino turns on the LED inside the opto-triac chip. Resistor R3 limits the LED current. This signal triggers the internal triac, which then passes 120 vac voltage, current limited by resistor R4, to the gate of a power triac, turning it on. This sends power to the attached light bulb. Note: you may also use a random turn-on type SSR relay module in place of this circuit, but at added cost.

While this build shows only one dimming channel, there is no reason why you couldn't add more channels by adding more lamp driver circuits and expanding the code.

Schematic

Schematic

Click on the figure to get a large version.

The Arduino sketch software works as follows:

Code listing:
/*---------------------------------------------
  title: HwMachArduino.ino
    Halloween Machine
  revisions:  4/08/08 - init release
             10/30/08 - port to lugger
              8/14/13 - port to Arduino
             10/01/17 - use PROGMEM
             10/02/17 - 4 bit brightness
             10/08/20 - limit moc3020m drive time

  (c) Telford Dorr 2008-2020

  Permission to use, copy, modify, and/or distribute this software for
  any purpose with or without fee is hereby granted, provided that the
  above copyright notice and this permission notice appear in all copies.

  THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
  WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
  MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR  BE LIABLE FOR
  ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
  WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

  ---------------------------------------------

  This sketch uses an Arduino to flash and dim a
  standard 120 vac incandescent lamp for a flickering
  candle Halloween effect. The entire sequence is
  approximately 56 seconds long, and repeats endlessly.

  The connecting hardware has one controllable
  dimming channel and one zero crossing input
  channel.

  The input port is low except during zero cross.

  The output port is wired up inverted
  polarity - pull down (output = LOW) to activate.

  This software will support multiple lighting channels,
  each with its own brightness sequence table. The
  only caveat is that all sequence tables need to
  be the same length. Add additional I/O and channel
  processing where indicated.

  Eash channel can be controlled to one of sixteen
  brightness levels.

  This is an all-software bit-banging method of
  controlling the channel brightness.

  The main loop controls the basic dimming
  sequence. There is a 'brightness' table for
  each controlled lighting channel. Each table
  value is repeated a specified number of line
  half-cycles (should be an even number) before
  advancing to the next value.

  Table values are fetched, and then the routine
  waits for line voltage zero crossing. Then
  the table values are compared against a
  downcounting counter. When the value matches,
  the appropriate channel is activated, and
  stays activated for the remainder of the line
  half-cycle.

  After each lighting channel is processed, a delay
  routine is called (500 usec). This controls the
  portion of 8.33 msec line half-cycle the
  channel is active, and thus its brightness.

  ---------------------------------------------*/

#include <avr/pgmspace.h>

#define ON LOW
#define OFF HIGH

// Global constants

const uint8_t OUTPORT1 = 13;
// Add more channel port defs here.

const uint8_t STATPORT = 12;
const uint8_t REPEATS = 8;


/*---------------------------------------------
  HW Machine data tables

  Each number represents a 'brightness'
  value. 15 = brightest, 0 = off

  Because this table is large and constant, and
  because we could expand to multiple channels,
  needing miltiple tables, the tables will be
  stored in program memory (hence PROGMEM).
  ---------------------------------------------*/

const uint8_t CHANNEL_1_TABLE[] PROGMEM =
  {
  15, 9,15, 9,13, 9,13, 9,  11, 7,11, 7, 9, 5, 9, 5,     // 1
   7, 3, 7, 3, 7, 3, 7, 3,   9, 5,11, 7,13, 9,15, 9,
  15, 9,13, 9,11, 7, 9, 5,   7, 2, 7, 2, 7, 2, 7, 2,
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,

  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,     // 2
   7, 3, 7, 3, 7, 2, 7, 2,   7, 2, 7, 2, 7, 2, 7, 2,
   9, 5, 9, 5,11, 7,11, 7,  12, 9,12, 9,15, 9,15, 9,
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,

   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,     // 3
  13, 9,11, 7, 9, 5, 7, 2,   9, 5,11, 7,13, 9,15, 9,
  15, 9,15, 9,13, 9,13, 9,  11, 7,11, 7, 9, 5, 9, 5,
   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,

  15, 9,13, 9,11, 7, 9, 5,   7, 2, 7, 2, 7, 2, 7, 2,     // 4
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,
   7, 2, 7, 2, 7, 2, 7, 2,   7, 2, 7, 2, 7, 2, 7, 2,

   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,     // 5
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,
   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,
  13, 9,11, 7, 9, 5, 7, 2,   9, 5,11, 7,13, 5,15, 9,

  15, 9,15, 9,13, 9,13, 9,  11, 7,11, 7, 9, 5, 9, 5,     // 6
   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,
  15, 9,13, 9,11, 7, 9, 5,   7, 2, 7, 2, 7, 2, 7, 2,
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,

  13, 9,13, 9,15, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,     // 7
   7, 2, 7, 2, 7, 2, 7, 2,   7, 2, 7, 2, 7, 2, 7, 2,
   7, 5, 7, 5, 11, 7,11, 7, 13, 9,13, 9,15, 9,15, 9,
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,

   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,     // 8
  13, 9,11, 7, 9, 5, 7, 2,   9, 5,11, 7,13, 9,15, 9,
  15, 9,15, 9,13, 9,13, 9,  11, 7,11, 7, 9, 5, 9, 5,
   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,

  15, 9,13, 9,11, 7, 9, 5,   7, 2, 7, 2, 7, 2, 7, 2,     // 9
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,
   7, 2, 7, 2, 7, 2, 7, 2,   7, 2, 7, 2, 7, 2, 7, 2,

   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,     // 10
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,
   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,
  13, 9,11, 7, 9, 5, 7, 2,   9, 5,11, 7,13, 9,15, 9,

  15, 9,15, 9,13, 9,13, 9,  11, 7,11, 7, 9, 5, 9, 5,     // 11
   7, 2, 7, 2, 7, 2, 7, 2,   9, 4,11, 7,13, 9,15, 9,
  15, 9,13, 9,11, 7, 9, 5,   7, 2, 7, 2, 7, 2, 7, 2,
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,

  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,     // 12
   7, 2, 7, 2, 7, 2, 7, 2,   7, 2, 7, 2, 7, 2, 7, 2,
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,

   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,     // 13
  13, 9,11, 7, 9, 5, 7, 2,   9, 5,11, 7,13, 9,15, 9,
  15, 9,15, 9,13, 9,13, 9,  11, 7,11, 7, 9, 5, 9, 5,
   7, 2, 7, 2, 7, 2, 7, 2,   9, 5,11, 7,13, 9,15, 9,

  15, 9,13, 9,15, 7, 9, 5,   7, 2, 7, 2, 7, 2, 7, 2,     // 14
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9,
  13, 9,13, 9,11, 7,11, 7,   9, 5, 9, 5, 7, 2, 7, 2,
   9, 5, 9, 5,11, 7,11, 7,  13, 9,13, 9,15, 9,15, 9
  };

// Add more channel tables here, if desired.
// All must be the same length.

#define SIZE_TABLE ((uint16_t)(sizeof CHANNEL_1_TABLE / sizeof CHANNEL_1_TABLE[ 0 ] ))


// Global vars

uint16_t table_index;
uint8_t half_cycle, brightness;
uint8_t table_1_value;
// Add more channel table value vars here.


// ---------------------------------------------

// Initialze hardware

void setup()
  {
  // The input port detects power line zero-crossing
  pinMode( STATPORT, INPUT );

  // The output port is wired up inverted polarity -
  // pull down an opto-coupler to activate.
  pinMode( OUTPORT1, OUTPUT );
  // Add more channel outputs here.

  digitalWrite( OUTPORT1, OFF );
  // Add more channel resets here.
  }


// ---------------------------------------------

// Wait for line zero crossing
// (the input port is low except during zero cross)

void sync()

  {
  // wait for zero-cross start (pin to go high)
  while (!digitalRead( STATPORT ))
    ;

  // cancel all output drive
  digitalWrite( OUTPORT1, OFF );
  // Add more channel resets here.

  // wait for xero-cross end (pin to go low)
  while (digitalRead( STATPORT ))
    ;
  }


// ---------------------------------------------

void loop()

  {
  // Table loop
  for (table_index = 0; table_index < SIZE_TABLE; ++table_index)
    {
    table_1_value = pgm_read_byte(CHANNEL_1_TABLE + table_index);
    // Add more channel table value loads here.
    // All use same table index.

    // Number of power line half-cycles loop
    for (half_cycle = 0; half_cycle < REPEATS; ++half_cycle)   // repeat 'N' half-cycles
      {
      // wait for line zero-cross
      sync();

      // Trigger loop
      for (brightness = 15; brightness > 0; --brightness )   // 15 = bright, 0 = off
        {
        // Process channel trigger
        if (table_1_value == brightness)
          digitalWrite( OUTPORT1, ON );
        // Add more channel trigger processing here.

        delayMicroseconds( 500 );       // waste time (usec)
        }

      // Brightness == 0 - kill drive
      // This kills drive well before zero crossing, which fixes issue
      // with current generation of MOC3010M devices.
      digitalWrite( OUTPORT1, OFF );
      }
    }
  }
        

Miscellaneous notes:

Downloads

Source code (.ino file)

Schematic (.pdf file)

Here's an alternate method using interrupts to capture zero crossing. It still polls for a flag set by the interrupt handler to start the cycle timing, so there's no real advantage, unless you have an extremely narrow zero crossing pulse, in which case this method won't miss it. The wiring is slightly different: the zero cross pulse moves from D13 to D3, which supports interrupts.

Source code (.ino file)

Schematic (.pdf file)