Built-in project · Arduino Uno R3

Factory 1: Mining drill, on an Arduino Uno

A drill digs ore and fills a hopper on a TFT. Press the button to unload it into the shipped counter, or watch the drill stop when the hopper is full. The whole build, an Arduino Uno and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 30 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $37. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Displays
  • Games
  • Sound
  • Choosing and reading a resistor
  • How an LED lights and why it needs a resistor
  • Difficulty: Intermediate
  • Time: About 30 minutes
  • Board: Arduino Uno R3
  • Parts: 9

How it works

A drill digs ore and fills a hopper on a TFT. Press the button to unload it into the shipped counter, or watch the drill stop when the hopper is full.

Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Piezo speaker, Resistor, 220 Ω, LED, green, Pushbutton. pinMode sets BUTTON_PIN as an input with its pull-up on. digitalWrite drives LED_PIN HIGH. digitalRead reads whether b.pin is HIGH or LOW. Serial.begin opens the serial port at 9600 baud. tone plays a note on PIEZO_PIN.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
TFT display, 240x320 pin SCKArduino Uno R3 pin 13
TFT display, 240x320 pin MOSIArduino Uno R3 pin 11
TFT display, 240x320 pin CSArduino Uno R3 pin 10
TFT display, 240x320 pin DCArduino Uno R3 pin 9
Piezo speaker pin 1Arduino Uno R3 pin 6
Resistor, 220 Ω pin 1Arduino Uno R3 pin 3
Pushbutton pin 1aArduino Uno R3 pin 2
Arduino Uno R3 pin GNDGround
TFT display, 240x320 pin VCC5 V
TFT display, 240x320 pin GNDGround
TFT display, 240x320 pin RST5 V
TFT display, 240x320 pin LED5 V
Piezo speaker pin 2Ground
LED, green pin AResistor, 220 Ω pin 2
LED, green pin CGround
Pushbutton pin 2aGround

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Factory 1: Mining drill. The first machine of the Factory world series.
//
// A drill digs ore out of a patch and drops it into a hopper. The hopper holds
// eight lumps. When it is full the drill has nowhere to put the next lump and
// stops, which is the first thing every factory teaches you: a machine that
// cannot hand its work on has to wait. Press the button to unload the hopper
// into the shipped counter and the drill gets back to work.
//
// Wiring, board to parts:
//   TFT   CS -> pin 10   DC -> pin 9   SCK -> pin 13   MOSI -> pin 11
//         RST and LED -> 5 V, VCC -> 5 V, GND -> GND
//   Unload button: pin 2 to GND (the pin's own pull-up holds it high)
//   Status LED:    pin 3 through 220 ohm to GND
//   Piezo:         pin 6 to GND
//
// # What this teaches
//
// 1. A state machine. The drill is always in exactly one state, DRILLING or
//    FULL, and each pass of loop() asks "what does this state do, and what
//    makes it change?". Nothing else decides what the drill does.
// 2. Timing without delay(). Drilling takes DRILL_MS. Instead of freezing the
//    board for that long, the sketch remembers when the lump started and asks
//    millis() how long ago that was, so the button stays alive the whole time.
// 3. Debouncing. A switch chatters for a few milliseconds when it closes, so
//    the sketch waits for the pin to settle before it believes a press.
// 4. Drawing only what changed. A TFT on an Uno is slow, so the world is
//    painted once and after that only the tiles that change are touched.
//
// # The world
//
// The picture is a grid of 16 pixel tiles, 14 across and 10 down. A tile is
// named by its column and row, and tileX() and tileY() turn that into pixels.

#include "mokxi_ili9341.h"

const uint8_t TFT_CS = 10;
const uint8_t TFT_DC = 9;
const uint8_t TFT_SCK = 13;
const uint8_t TFT_MOSI = 11;
const uint8_t BUTTON_PIN = 2;
const uint8_t LED_PIN = 3;
const uint8_t PIEZO_PIN = 6;

SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);

const int16_t TILE = 16;
const int16_t WORLD_X = 8;
const int16_t WORLD_Y = 8;
const uint8_t COLS = 14;
const uint8_t ROWS = 10;

// The panel powers up black, so black is the ground: nothing needs painting
// until something is built on it, which saves repainting 36,000 pixels at start.
const uint16_t GROUND = ILI9341_BLACK;
const uint16_t ROCK = 0x4a49;
const uint16_t ORE = 0xfc60;
const uint16_t STEEL = 0x8c71;
const uint16_t DARK = 0x10a2;
const uint16_t GOOD = ILI9341_GREEN;
const uint16_t BAD = ILI9341_RED;

// Where things stand in the world.
const uint8_t DRILL_COL = 5;
const uint8_t DRILL_ROW = 4;
const uint8_t CHUTE_COL = 6;
const uint8_t HOPPER_COL = 8;     // the hopper is two tiles wide ...
const uint8_t HOPPER_ROW = 3;     // ... and four tall, eight slots in all
const uint8_t HOPPER_SLOTS = 8;

const unsigned long DRILL_MS = 1200;   // one lump takes this long to dig
const unsigned long CHUTE_MS = 180;    // how long the chute flashes

// A tactile switch does not close once: the contacts bounce for a few
// milliseconds, so a raw read of the pin can see several presses from one
// finger. This waits until the pin has held the same level for 20 ms, then
// reports the press exactly once.
struct Button {
  uint8_t pin;
  bool raw;                // what the pin read last time round
  bool stable;             // the settled state
  unsigned long since;     // when `raw` last changed
};

static bool pressedOnce(Button &b) {
  bool down = digitalRead(b.pin) == LOW;
  unsigned long now = millis();
  if (down != b.raw) {
    b.raw = down;
    b.since = now;
  }
  if (now - b.since >= 20 && b.stable != b.raw) {
    b.stable = b.raw;
    return b.stable;
  }
  return false;
}

enum DrillState { DRILLING, FULL };

DrillState state = DRILLING;
unsigned long lumpStarted = 0;  // millis() when the current lump began
uint8_t progress = 0;           // 0..16, how much of the lump is dug
uint8_t hopper = 0;             // lumps waiting in the hopper
uint16_t shipped = 0;           // lumps unloaded so far
unsigned long chuteUntil = 0;   // the chute glows until this time
bool chuteLit = false;
bool bitFrame = false;          // which of the two drill-bit pictures
unsigned long bitSwitched = 0;
Button unloadButton = {BUTTON_PIN, false, false, 0};
unsigned long ledToggled = 0;
bool ledOn = false;

static int16_t tileX(uint8_t col) { return (int16_t)(WORLD_X + col * TILE); }
static int16_t tileY(uint8_t row) { return (int16_t)(WORLD_Y + row * TILE); }

static void fillTile(uint8_t col, uint8_t row, uint16_t colour) {
  tft.fillRect(tileX(col), tileY(row), TILE, TILE, colour);
}

// A square drawn inside a tile, `pad` pixels in from every edge.
static void insetTile(uint8_t col, uint8_t row, int16_t pad, uint16_t colour) {
  tft.fillRect((int16_t)(tileX(col) + pad), (int16_t)(tileY(row) + pad), (int16_t)(TILE - 2 * pad),
               (int16_t)(TILE - 2 * pad), colour);
}

static void drawOreTile(uint8_t col, uint8_t row) {
  fillTile(col, row, ROCK);
  // Two specks of ore. Their spot depends on the tile so the patch looks natural.
  int16_t a = (int16_t)(2 + (col * 5 + row * 3) % 6);
  int16_t b = (int16_t)(3 + (col * 3 + row * 7) % 6);
  tft.fillRect((int16_t)(tileX(col) + a), (int16_t)(tileY(row) + b), 4, 4, ORE);
  tft.fillRect((int16_t)(tileX(col) + 12 - a / 2), (int16_t)(tileY(row) + 12 - b / 2), 3, 3, ORE);
}

// The drill is a steel box with a bit that turns. Two pictures, swapped over
// and over, make it look like it is spinning.
static void drawDrill(bool working, bool frame) {
  fillTile(DRILL_COL, DRILL_ROW, STEEL);
  insetTile(DRILL_COL, DRILL_ROW, 2, DARK);
  uint16_t bit = working ? ORE : BAD;
  if (frame) {
    tft.fillRect((int16_t)(tileX(DRILL_COL) + 3), (int16_t)(tileY(DRILL_ROW) + 7), 10, 2, bit);
  } else {
    tft.fillRect((int16_t)(tileX(DRILL_COL) + 7), (int16_t)(tileY(DRILL_ROW) + 3), 2, 10, bit);
  }
}

// A thin bar under the drill that fills as the lump is dug.
static void drawProgress() {
  int16_t x = tileX(DRILL_COL);
  int16_t y = (int16_t)(tileY(DRILL_ROW + 1) + 4);
  tft.fillRect(x, y, TILE, 5, DARK);
  if (progress > 0) {
    tft.fillRect(x, y, progress, 5, state == FULL ? BAD : GOOD);
  }
}

static void drawChute(bool lit) {
  fillTile(CHUTE_COL, DRILL_ROW, GROUND);
  fillTile(CHUTE_COL + 1, DRILL_ROW, GROUND);
  uint16_t c = lit ? ORE : STEEL;
  tft.fillRect(tileX(CHUTE_COL), (int16_t)(tileY(DRILL_ROW) + 6), (int16_t)(2 * TILE), 4, c);
}

// Slot 0 is the bottom-left of the hopper, slot 1 the bottom-right, and so on up.
static void drawSlot(uint8_t slot) {
  uint8_t col = (uint8_t)(HOPPER_COL + (slot & 1));
  uint8_t row = (uint8_t)(HOPPER_ROW + 3 - (slot >> 1));
  fillTile(col, row, STEEL);
  insetTile(col, row, 2, slot < hopper ? ORE : DARK);
}

static void drawHopper() {
  for (uint8_t i = 0; i < HOPPER_SLOTS; i++) {
    drawSlot(i);
  }
}

// Text is the slowest thing this board draws: every letter is a dozen small
// rectangles, and each rectangle is its own trip down the wire. So the words
// are painted once, and afterwards only the character or two that changed.
static void drawLabels() {
  tft.text(8, 176, "HOPPER", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(8 + 8 * 12, 176, "/8", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(8, 198, "SHIPPED", ILI9341_WHITE, ILI9341_BLACK, 2);
}

static void drawCounts() {
  tft.number(8 + 7 * 12, 176, hopper, ILI9341_YELLOW, ILI9341_BLACK, 2, 1);
  tft.number(8 + 8 * 12, 198, shipped, ILI9341_YELLOW, ILI9341_BLACK, 2, 4);
}

static void drawStatus() {
  tft.text(8, 220, state == FULL ? "FULL!" : "DIG  ", state == FULL ? BAD : GOOD, ILI9341_BLACK, 2);
}

static void drawWorld() {
  tft.rect(WORLD_X - 3, WORLD_Y - 3, COLS * TILE + 6, ROWS * TILE + 6, ROCK, 2);
  // The ore patch: a 4 by 4 block of rock with ore in it.
  for (uint8_t c = 1; c < 5; c++) {
    for (uint8_t r = 3; r < 7; r++) {
      drawOreTile(c, r);
    }
  }
  drawChute(false);
  drawHopper();
  // A little crate for the shipped lumps, so the unload has somewhere to go.
  fillTile(12, 5, STEEL);
  insetTile(12, 5, 3, DARK);
  drawDrill(true, false);
  drawProgress();
  drawLabels();
  drawCounts();
  drawStatus();
}

static void beep(unsigned int hz, unsigned int ms) { tone(PIEZO_PIN, hz, ms); }

static void report() {
  Serial.print("hopper ");
  Serial.print(hopper);
  Serial.print(" shipped ");
  Serial.println(shipped);
}

// The drill finished a lump: move it to the hopper.
static void lumpDone() {
  // Tell the serial monitor first: it is instant, and the picture is not.
  hopper++;
  progress = 0;
  lumpStarted = millis();
  chuteUntil = lumpStarted + CHUTE_MS;
  report();
  drawSlot((uint8_t)(hopper - 1));
  drawCounts();
  beep(880, 40);
}

static void unload() {
  if (hopper == 0) {
    return;
  }
  shipped = (uint16_t)(shipped + hopper);
  hopper = 0;
  report();
  beep(440, 80);
  drawHopper();
  if (state == FULL) {
    state = DRILLING;
    lumpStarted = millis();
    progress = 0;
    drawDrill(true, bitFrame);
    drawProgress();
    drawStatus();
  }
  drawCounts();
}

void setup() {
  Serial.begin(9600);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  tft.begin();
  drawWorld();
  lumpStarted = millis();
  report();
}

void loop() {
  unsigned long now = millis();

  // The button: one press, one unload, however long it is held.
  if (pressedOnce(unloadButton)) {
    unload();
  }

  // The state machine.
  if (state == DRILLING) {
    // How far through the lump are we, in sixteenths? millis() does the timing.
    uint8_t now16 = (uint8_t)(((now - lumpStarted) * TILE) / DRILL_MS);
    if (now16 > TILE) {
      now16 = TILE;
    }
    if (now16 != progress) {
      progress = now16;
      drawProgress();
    }
    if (now - lumpStarted >= DRILL_MS) {
      if (hopper < HOPPER_SLOTS) {
        lumpDone();
      } else {
        state = FULL;
        beep(220, 200);
        drawDrill(false, bitFrame);
        drawProgress();
        drawStatus();
      }
    }
    // Spin the bit.
    if (now - bitSwitched >= 150) {
      bitSwitched = now;
      bitFrame = !bitFrame;
      drawDrill(true, bitFrame);
    }
  }

  // The chute glows for a moment after each lump.
  bool lit = now < chuteUntil;
  if (lit != chuteLit) {
    chuteLit = lit;
    drawChute(lit);
  }

  // The LED: steady while drilling, blinking when the hopper is full.
  if (state == DRILLING) {
    digitalWrite(LED_PIN, HIGH);
  } else if (now - ledToggled >= 250) {
    ledToggled = now;
    ledOn = !ledOn;
    digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
  }
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup() {
  Serial.begin(9600);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  tft.begin();
  drawWorld();
  lumpStarted = millis();
  report();
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop() {
  unsigned long now = millis();

  // The button: one press, one unload, however long it is held.
  if (pressedOnce(unloadButton)) {
    unload();
  }

  // The state machine.
  if (state == DRILLING) {
    // How far through the lump are we, in sixteenths? millis() does the timing.
    uint8_t now16 = (uint8_t)(((now - lumpStarted) * TILE) / DRILL_MS);
    if (now16 > TILE) {
      now16 = TILE;
    }
    if (now16 != progress) {
      progress = now16;
      drawProgress();
    }
    if (now - lumpStarted >= DRILL_MS) {
      if (hopper < HOPPER_SLOTS) {
        lumpDone();
      } else {
        state = FULL;
        beep(220, 200);
        drawDrill(false, bitFrame);
        drawProgress();
        drawStatus();
  // ... 26 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The screen stays white or black. TFT display
    Check the SPI wiring: CS, DC, RST, MOSI, SCK. Power the module at 3.3 V logic, or use level shifting on 5 V boards. Call begin() and set the rotation.
  • Slow drawing or garbled colors. TFT display
    Use hardware SPI pins and avoid redrawing the whole screen. Fill only changed areas. Confirm the library matches ILI9341 and the color order.
  • A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
    A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead.
  • Sound is very quiet or the pin seems overloaded. Piezo speaker
    Drive the piezo from a pin directly, or through a 100 ohm resistor. For a bigger buzzer, switch it with a transistor. Check that you wired the right two legs and joined grounds.
  • The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
    Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure.
  • A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
    Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground.
  • The LED does not light, or it lights once and then dies. LED
    LEDs have polarity. The longer leg (anode) goes toward the positive side. Always add a series resistor, about 220 ohms to 1 k ohm on a 5 V pin.
  • A blue or white LED stays dark on 3.3 V. LED
    Blue and white LEDs drop about 3 V, so 3.3 V leaves almost nothing for the resistor. Use a 5 V supply, or a smaller resistor and a red or green LED.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.