Built-in project · Arduino Uno R3

Factory 10: Build your own, on an Arduino Uno

Design your own factory with a keypad: type two-digit codes to place belts, drills, presses and chests, then run it and watch ore turn into plates. 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.

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

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

The circuit runs here once the page has loaded.

What you'll learn

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

How it works

Design your own factory with a keypad: type two-digit codes to place belts, drills, presses and chests, then run it and watch ore turn into plates.

Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Keypad, 4x4, Resistor, 220 Ω, LED, green, Piezo speaker. pinMode sets LAMP_PIN as an output. Serial.begin opens the serial port at 9600 baud. tone plays a note on PIEZO_PIN. millis reads the time since power-up, so the sketch can time things without stopping.

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
Keypad, 4x4 pin C4Arduino Uno R3 pin 12
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
Keypad, 4x4 pin C3Arduino Uno R3 pin 8
Keypad, 4x4 pin C2Arduino Uno R3 pin 7
Keypad, 4x4 pin C1Arduino Uno R3 pin 6
Keypad, 4x4 pin R4Arduino Uno R3 pin 5
Keypad, 4x4 pin R3Arduino Uno R3 pin 4
Keypad, 4x4 pin R2Arduino Uno R3 pin 3
Keypad, 4x4 pin R1Arduino Uno R3 pin 2
Arduino Uno R3 pin GNDGround
Resistor, 220 Ω pin 1Arduino Uno R3 pin A4
Piezo speaker pin 1Arduino Uno R3 pin A5
TFT display, 240x320 pin VCC5 V
TFT display, 240x320 pin GNDGround
TFT display, 240x320 pin RST5 V
TFT display, 240x320 pin LED5 V
LED, green pin AResistor, 220 Ω pin 2
LED, green pin CGround
Piezo speaker pin 2Ground

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 10: Build your own. A keypad is your toolbox.
//
// Put together everything from the series: drills dig ore, belts carry it,
// presses turn ore into plates, chests collect whatever arrives. You lay the
// factory out yourself with a 4x4 keypad, then run it.
//
// The keys:
//   A B C D   move the cursor up, down, left, right
//   0 to 9    type a two-digit tile code
//   #         place the tile for the code on the cursor
//   *         run or stop the factory
//
// The codes:
//   00 erase          10 belt right     11 belt down     12 belt left     13 belt up
//   20 drill          30 press          40 chest         50 wall
//   99 load a demo factory                98 clear everything
//
// A drill pushes ore onto any belt or into any chest or press next to it. A
// belt moves one item a step in the way its arrow points. A press takes one ore
// and, a moment later, puts out one plate. A chest accepts anything and counts it.
//
// 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
//   Keypad rows R1..R4 -> pins 2, 3, 4, 5     columns C1..C4 -> pins 6, 7, 8, 12
//   Run lamp: A4 through 220 ohm to GND       Piezo: A5 to GND
//
// # What this teaches
//
// 1. Input as a small state machine. Typing a code is two digits and then '#'.
//    The sketch remembers how many digits it has, throws a third one away by
//    starting over, and only acts when '#' arrives with a complete code.
// 2. A world as data. The whole factory is two arrays, one saying what each
//    tile is and one saying what it is carrying. The screen is just a view of
//    those arrays, redrawn only where they change.
// 3. A simulation step. Every 250 ms each tile looks at its neighbors and
//    makes its one small move. Complicated behavior, a working production
//    line, comes from many tiles each following one simple rule. The same idea
//    runs games of life and traffic models.
// 4. Everything before this is here too: states, timers, ratios, queues
//    and flow. Build a factory that makes plates, then build one that makes
//    more.

#include "mokxi_ili9341.h"
#include "mokxi_keypad.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 LAMP_PIN = A4;
const uint8_t PIEZO_PIN = A5;

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

const uint8_t ROW_PINS[4] = {2, 3, 4, 5};
const uint8_t COL_PINS[4] = {6, 7, 8, 12};
Keypad pad(ROW_PINS, COL_PINS);

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;
const uint8_t CELLS = COLS * ROWS;

// What a tile is.
enum Tile : uint8_t { EMPTY, BELT_R, BELT_D, BELT_L, BELT_U, DRILL, PRESS, CHEST, WALL };
// What a tile carries.
enum Item : uint8_t { NONE, ORE, PLATE };

const uint16_t STEEL = 0x8c71;
const uint16_t DARK = 0x10a2;
const uint16_t BELT = 0x31a6;
const uint16_t ARROW = 0x7bef;
const uint16_t ORE_COLOUR = 0xfc60;
const uint16_t PLATE_COLOUR = 0xc618;
const uint16_t PRESS_COLOUR = 0x780f;

const unsigned long TICK_MS = 250;
const uint8_t DRILL_TICKS = 4;     // a drill makes an ore every 4 ticks
const uint8_t PRESS_TICKS = 3;     // a press takes 3 ticks per plate

uint8_t tile[CELLS];
uint8_t item[CELLS];
uint8_t timer[CELLS];              // drills and presses count their work here
bool moved[CELLS];                 // an item moves once per tick
bool dirty[CELLS];                 // this tile must be drawn again
uint8_t cursor = 4 * COLS + 2;
bool running = false;
unsigned long lastTick = 0;
unsigned int oreIn = 0, plateIn = 0;   // what the chests have collected
unsigned int shownOre = 65535, shownPlates = 65535;
bool shownRunning = true;
int shownCode = -2;
int8_t shownMeaning = -2;

// The code being typed: -1 for nothing, otherwise how many digits so far.
int digits = 0;
int codeValue = 0;

static uint8_t at(uint8_t c, uint8_t r) { return (uint8_t)(r * COLS + c); }
static uint8_t colOf(uint8_t i) { return (uint8_t)(i % COLS); }
static uint8_t rowOf(uint8_t i) { return (uint8_t)(i / COLS); }

static bool isBelt(uint8_t t) { return t >= BELT_R && t <= BELT_U; }

// The tile a belt points at, or 255 if that is off the edge.
static uint8_t facing(uint8_t i) {
  int c = colOf(i), r = rowOf(i);
  switch (tile[i]) {
    case BELT_R: c++; break;
    case BELT_D: r++; break;
    case BELT_L: c--; break;
    case BELT_U: r--; break;
  }
  if (c < 0 || c >= COLS || r < 0 || r >= ROWS) {
    return 255;
  }
  return at((uint8_t)c, (uint8_t)r);
}

// ---- drawing ---------------------------------------------------------------

static void drawItem(int16_t x, int16_t y, uint8_t it) {
  if (it == ORE) {
    tft.fillRect((int16_t)(x + 5), (int16_t)(y + 5), 6, 6, ORE_COLOUR);
  } else if (it == PLATE) {
    tft.fillRect((int16_t)(x + 4), (int16_t)(y + 6), 8, 4, PLATE_COLOUR);
  }
}

static void drawTile(uint8_t i) {
  int16_t x = (int16_t)(WORLD_X + colOf(i) * TILE);
  int16_t y = (int16_t)(WORLD_Y + rowOf(i) * TILE);
  uint8_t t = tile[i];
  tft.fillRect(x, y, TILE, TILE, ILI9341_BLACK);
  if (isBelt(t)) {
    tft.fillRect(x, y, TILE, TILE, BELT);
    // an arrow: a shaft along the belt and a head at the front
    if (t == BELT_R || t == BELT_L) {
      int16_t head = t == BELT_R ? (int16_t)(x + 11) : (int16_t)(x + 1);
      tft.fillRect((int16_t)(x + 2), (int16_t)(y + 7), 12, 2, ARROW);
      tft.fillRect(head, (int16_t)(y + 5), 4, 6, ARROW);
    } else {
      int16_t head = t == BELT_D ? (int16_t)(y + 11) : (int16_t)(y + 1);
      tft.fillRect((int16_t)(x + 7), (int16_t)(y + 2), 2, 12, ARROW);
      tft.fillRect((int16_t)(x + 5), head, 6, 4, ARROW);
    }
    drawItem(x, y, item[i]);
  } else if (t == DRILL) {
    tft.fillRect(x, y, TILE, TILE, STEEL);
    tft.fillRect((int16_t)(x + 4), (int16_t)(y + 4), 8, 8, ORE_COLOUR);
  } else if (t == PRESS) {
    tft.fillRect(x, y, TILE, TILE, STEEL);
    tft.fillRect((int16_t)(x + 3), (int16_t)(y + 3), 10, 10, PRESS_COLOUR);
    if (item[i] != NONE) {
      tft.fillRect((int16_t)(x + 6), (int16_t)(y + 6), 4, 4, item[i] == ORE ? ORE_COLOUR : PLATE_COLOUR);
    }
  } else if (t == CHEST) {
    tft.fillRect(x, y, TILE, TILE, STEEL);
    tft.fillRect((int16_t)(x + 3), (int16_t)(y + 3), 10, 10, DARK);
  } else if (t == WALL) {
    tft.fillRect(x, y, TILE, TILE, 0x4208);
    tft.rect(x, y, TILE, TILE, DARK, 2);
  }
  if (i == cursor) {
    tft.rect(x, y, TILE, TILE, ILI9341_YELLOW, 2);
  }
  dirty[i] = false;
}

static void drawLabels() {
  tft.text(8, 176, "ORE", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(104, 176, "PLATES", ILI9341_WHITE, ILI9341_BLACK, 2);
  tft.text(8, 198, "CODE", ILI9341_WHITE, ILI9341_BLACK, 2);
}

// What a code means, for the line under the screen.
static const char *meaningOf(int code) {
  switch (code) {
    case 0: return "ERASE        ";
    case 10: return "BELT RIGHT   ";
    case 11: return "BELT DOWN    ";
    case 12: return "BELT LEFT    ";
    case 13: return "BELT UP      ";
    case 20: return "DRILL        ";
    case 30: return "PRESS        ";
    case 40: return "CHEST        ";
    case 50: return "WALL         ";
    case 99: return "LOAD DEMO    ";
    case 98: return "CLEAR ALL    ";
    default: return "NO SUCH CODE ";
  }
}

static void report() {
  uint8_t tiles = 0;
  for (uint8_t i = 0; i < CELLS; i++) {
    if (tile[i] != EMPTY) {
      tiles++;
    }
  }
  Serial.print("tiles ");
  Serial.print(tiles);
  Serial.print(" run ");
  Serial.print(running ? 1 : 0);
  Serial.print(" ore ");
  Serial.print(oreIn);
  Serial.print(" plates ");
  Serial.println(plateIn);
}

// One drawing job per call: a changed tile first, then the numbers.
static void refresh() {
  for (uint8_t i = 0; i < CELLS; i++) {
    if (dirty[i]) {
      drawTile(i);
      return;
    }
  }
  if (oreIn != shownOre) {
    shownOre = oreIn;
    tft.number(8 + 4 * 12, 176, oreIn, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
  } else if (plateIn != shownPlates) {
    shownPlates = plateIn;
    tft.number(104 + 7 * 12, 176, plateIn, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
  } else if (running != shownRunning) {
    shownRunning = running;
    tft.text(8 + 11 * 12, 198, running ? "RUN " : "STOP", running ? ILI9341_GREEN : ILI9341_RED, ILI9341_BLACK, 2);
  } else if (codeValue + 100 * digits != shownCode) {
    shownCode = codeValue + 100 * digits;
    // the digits typed so far, with dashes for the ones still to come
    char text[3] = {'-', '-', 0};
    if (digits >= 1) {
      text[0] = (char)('0' + codeValue / 10);
    }
    if (digits >= 2) {
      text[1] = (char)('0' + codeValue % 10);
    }
    tft.text(8 + 5 * 12, 198, text, ILI9341_CYAN, ILI9341_BLACK, 2);
    tft.text(8, 220, digits == 2 ? meaningOf(codeValue) : "             ", ILI9341_WHITE, ILI9341_BLACK, 2);
  }
}

// ---- building --------------------------------------------------------------

static void place(uint8_t i, uint8_t t) {
  tile[i] = t;
  item[i] = NONE;
  timer[i] = 0;
  dirty[i] = true;
}

static void clearAll() {
  for (uint8_t i = 0; i < CELLS; i++) {
    place(i, EMPTY);
  }
  oreIn = plateIn = 0;
}

// A small working factory to start from: two drills, a press and a chest.
static void loadDemo() {
  clearAll();
  place(at(2, 4), DRILL);
  for (uint8_t c = 3; c <= 5; c++) {
    place(at(c, 4), BELT_R);
  }
  place(at(6, 4), PRESS);
  place(at(7, 4), BELT_R);
  place(at(8, 4), BELT_R);
  place(at(9, 4), CHEST);
  place(at(4, 2), DRILL);
  place(at(4, 3), BELT_D);
}

// Act on a complete code. Returns false for a code that means nothing.
static bool applyCode(int code) {
  switch (code) {
    case 0: place(cursor, EMPTY); return true;
    case 10: place(cursor, BELT_R); return true;
    case 11: place(cursor, BELT_D); return true;
    case 12: place(cursor, BELT_L); return true;
    case 13: place(cursor, BELT_U); return true;
    case 20: place(cursor, DRILL); return true;
    case 30: place(cursor, PRESS); return true;
    case 40: place(cursor, CHEST); return true;
    case 50: place(cursor, WALL); return true;
    case 99: loadDemo(); return true;
    case 98: clearAll(); return true;
  }
  return false;
}

// ---- the simulation --------------------------------------------------------

// Could the tile at `to` take an item of this kind from `from` right now?
static bool accepts(uint8_t to, uint8_t from, uint8_t what) {
  uint8_t t = tile[to];
  if (t == CHEST) {
    return true;
  }
  if (t == PRESS) {
    return what == ORE && item[to] == NONE;
  }
  if (isBelt(t)) {
    return item[to] == NONE && facing(to) != from;      // not onto a belt that points back
  }
  return false;
}

static void deliver(uint8_t to, uint8_t what) {
  if (tile[to] == CHEST) {
    if (what == ORE) {
      oreIn++;
    } else {
      plateIn++;
    }
    tone(PIEZO_PIN, what == ORE ? 700 : 1100, 25);
    report();
  } else {
    item[to] = what == ORE && tile[to] == PRESS ? ORE : what;
    timer[to] = 0;
    moved[to] = true;
    dirty[to] = true;
  }
}

// Offer an item to the four neighbors of `from`, in a fixed order. True if taken.
static bool pushOut(uint8_t from, uint8_t what) {
  uint8_t c = colOf(from), r = rowOf(from);
  uint8_t neighbors[4] = {255, 255, 255, 255};
  if (c + 1 < COLS) neighbors[0] = at((uint8_t)(c + 1), r);
  if (r + 1 < ROWS) neighbors[1] = at(c, (uint8_t)(r + 1));
  if (c > 0) neighbors[2] = at((uint8_t)(c - 1), r);
  if (r > 0) neighbors[3] = at(c, (uint8_t)(r - 1));
  for (uint8_t k = 0; k < 4; k++) {
    uint8_t n = neighbors[k];
    if (n != 255 && accepts(n, from, what)) {
      deliver(n, what);
      return true;
    }
  }
  return false;
}

static void stepTile(uint8_t i) {
  switch (tile[i]) {
    case DRILL:
      timer[i]++;
      if (timer[i] >= DRILL_TICKS) {
        if (pushOut(i, ORE)) {
          timer[i] = 0;
        } else {
          timer[i] = DRILL_TICKS;            // wait, full, until there is room
        }
      }
      break;
    case PRESS:
      if (item[i] == ORE && !moved[i]) {
        timer[i]++;
        if (timer[i] >= PRESS_TICKS) {
          item[i] = PLATE;
          timer[i] = 0;
          dirty[i] = true;
        }
      } else if (item[i] == PLATE && pushOut(i, PLATE)) {
        item[i] = NONE;
        dirty[i] = true;
      }
      break;
    default:
      if (isBelt(tile[i]) && item[i] != NONE && !moved[i]) {
        uint8_t to = facing(i);
        if (to != 255 && accepts(to, i, item[i])) {
          uint8_t what = item[i];
          item[i] = NONE;
          dirty[i] = true;
          deliver(to, what);
        }
      }
      break;
  }
}

static void tick() {
  for (uint8_t i = 0; i < CELLS; i++) {
    moved[i] = false;
  }
  for (uint8_t i = 0; i < CELLS; i++) {
    stepTile(i);
  }
  // a second sweep the other way lets a queue of items shuffle forward together
  for (int i = CELLS - 1; i >= 0; i--) {
    if (isBelt(tile[i])) {
      stepTile((uint8_t)i);
    }
  }
}

static void moveCursor(int dc, int dr) {
  int c = colOf(cursor) + dc, r = rowOf(cursor) + dr;
  if (c < 0 || c >= COLS || r < 0 || r >= ROWS) {
    return;
  }
  dirty[cursor] = true;
  cursor = at((uint8_t)c, (uint8_t)r);
  dirty[cursor] = true;
}

static void handleKey(char key) {
  if (key >= '0' && key <= '9') {
    if (digits >= 2) {
      digits = 0;                            // a third digit starts a new code
      codeValue = 0;
    }
    codeValue = codeValue * 10 + (key - '0');
    digits++;
    tone(PIEZO_PIN, 500, 15);
  } else if (key == '#') {
    if (digits == 2 && applyCode(codeValue)) {
      tone(PIEZO_PIN, 900, 40);
      report();
    } else {
      tone(PIEZO_PIN, 180, 120);             // nothing to place, or not a code
    }
    digits = 0;
    codeValue = 0;
  } else if (key == 'A') {
    if (digits > 0) {
      digits = 0;                            // A with a code half typed cancels it
      codeValue = 0;
    } else {
      moveCursor(0, -1);
    }
  } else if (key == 'B') {
    moveCursor(0, 1);
  } else if (key == 'C') {
    moveCursor(-1, 0);
  } else if (key == 'D') {
    moveCursor(1, 0);
  } else if (key == '*') {
    running = !running;
    digitalWrite(LAMP_PIN, running ? HIGH : LOW);
    lastTick = millis();
    report();
  }
}

void setup() {
  Serial.begin(9600);
  pinMode(LAMP_PIN, OUTPUT);
  pad.begin();
  tft.begin();
  tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
  for (uint8_t i = 0; i < CELLS; i++) {
    dirty[i] = true;
  }
  drawLabels();
  report();
}

void loop() {
  char key = pad.pressed();
  if (key != 0) {
    handleKey(key);
  }
  if (running && millis() - lastTick >= TICK_MS) {
    lastTick = millis();
    tick();
  }
  refresh();
}

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(LAMP_PIN, OUTPUT);
  pad.begin();
  tft.begin();
  tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
  for (uint8_t i = 0; i < CELLS; i++) {
    dirty[i] = true;
  }
  drawLabels();
  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() {
  char key = pad.pressed();
  if (key != 0) {
    handleKey(key);
  }
  if (running && millis() - lastTick >= TICK_MS) {
    lastTick = millis();
    tick();
  }
  refresh();
}

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.
  • Pressing one key reports several keys, or the wrong key. Keypad
    Match the keymap and the row and column pin order to your keypad's pinout. Without diodes, pressing three keys at once can ghost a fourth.
  • No keys are detected. Keypad
    Rows need pull-ups, or use INPUT_PULLUP, while one column is driven low at a time. The Keypad library does this for you if you pass the pins in the right order.
  • 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.