Factory 2: Belts, on an Arduino Uno
A conveyor belt on a TFT, driven by a knob: turn right to run it forward, left to run it backward, and watch the lumps ride at the speed you set. The whole build, an Arduino Uno and 6 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.
What you'll learn
- Displays
- Games
- Sensors
- 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: 11
How it works
A conveyor belt on a TFT, driven by a knob: turn right to run it forward, left to run it backward, and watch the lumps ride at the speed you set.
Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Piezo speaker, Resistor, 220 Ω, LED, green, Pushbutton, Potentiometer. 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. analogRead measures the voltage on KNOB_PIN as a number. Serial.begin opens the serial port at 9600 baud.
Parts list
11 parts, plus the jumper wires. They retail for about $37; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × TFT display, 240x320In Handheld Console Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, greenIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × PotentiometerIn Halloween Prop Kit, Starter Projects Kit.
- 4 × Power, 5 V
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 pin | Connects to |
|---|---|
| TFT display, 240x320 pin SCK | Arduino Uno R3 pin 13 |
| TFT display, 240x320 pin MOSI | Arduino Uno R3 pin 11 |
| TFT display, 240x320 pin CS | Arduino Uno R3 pin 10 |
| TFT display, 240x320 pin DC | Arduino Uno R3 pin 9 |
| Piezo speaker pin 1 | Arduino Uno R3 pin 6 |
| Resistor, 220 Ω pin 1 | Arduino Uno R3 pin 3 |
| Pushbutton pin 1a | Arduino Uno R3 pin 2 |
| Arduino Uno R3 pin GND | Ground |
| Potentiometer pin 2 | Arduino Uno R3 pin A0 |
| TFT display, 240x320 pin VCC | 5 V |
| TFT display, 240x320 pin GND | Ground |
| TFT display, 240x320 pin RST | 5 V |
| TFT display, 240x320 pin LED | 5 V |
| Piezo speaker pin 2 | Ground |
| LED, green pin A | Resistor, 220 Ω pin 2 |
| LED, green pin C | Ground |
| Pushbutton pin 2a | Ground |
| Potentiometer pin 1 | Ground |
| Potentiometer pin 3 | 5 V |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Factory 2: Belts. Ore rides a conveyor, and a knob drives it.
//
// A feeder drops lumps of ore on one end of a twelve tile belt. The belt
// carries them to the other end, where a bin counts them. Turn the knob right
// of center and the belt runs forward, left of center and it runs backward,
// and in the middle it stops. The further you turn, the faster it goes.
//
// 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
// Speed knob (10k pot): outer legs to 5 V and GND, middle leg -> A0
// Feeder button: pin 2 to GND (pauses and restarts the feeder)
// Status LED: pin 3 through 220 ohm to GND
// Piezo: pin 6 to GND
//
// # What this teaches
//
// 1. Reading an analog input and turning it into a control. analogRead() gives
// 0 to 1023. The middle of that range means "stop", so a dead zone around
// 512 keeps the belt still even when the knob is a little noisy. Outside the
// dead zone the distance from center is the speed, and the side is the
// direction.
// 2. Motion from elapsed time. Drawing a frame takes the Uno a while, and how
// long it takes changes. So the lumps do not move "one step per frame":
// each frame asks how many milliseconds really went by and moves the lumps
// that share of the way. Speed is "pixels per TICK_MS", and the picture
// keeps the same pace whether a frame takes 50 ms or 150.
// 3. Position as data. A lump is nothing but a number: how many pixels along
// the belt it is. Drawing is a separate step that turns that number into
// pixels. Keeping those two apart is how every game and every simulation is
// built.
#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 KNOB_PIN = A0;
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 uint16_t STEEL = 0x8c71;
const uint16_t BELT = 0x31a6;
const uint16_t STRIPE = 0x52aa;
const uint16_t ORE = 0xfc60;
const uint16_t DARK = 0x10a2;
// The belt runs along row 4, from column 2 to column 13.
const uint8_t BELT_ROW = 4;
const uint8_t BELT_FIRST = 2;
const uint8_t BELT_TILES = 12;
const int16_t BELT_PX = BELT_TILES * TILE; // 192 pixels long
const int16_t LUMP = 8; // a lump is 8 pixels square
// 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;
}
const uint8_t MAX_LUMPS = 8;
const unsigned long TICK_MS = 70; // speed 1 moves one pixel every TICK_MS
const unsigned long FEED_MS = 900; // a new lump every so often
const int DEAD_ZONE = 40; // knob values within this of 512 mean stop
const int MAX_SPEED = 6; // pixels per tick at full turn
// A lump's place on the belt, in pixels from the left end. -1 means no lump.
int16_t lump[MAX_LUMPS];
int8_t speed = 0; // -MAX_SPEED..MAX_SPEED; the sign is the direction
int8_t drawnSpeed = 99; // what the HUD last showed
uint8_t phase = 0; // how far the stripes have slid, 0..7
bool feederOn = true;
Button feederButton = {BUTTON_PIN, false, false, 0};
uint16_t delivered = 0; // lumps that reached the bin at the far end
uint16_t returned = 0; // lumps that went back out the feeder end
unsigned long lastTick = 0;
long carry = 0; // the part of a pixel the belt has moved but not yet shown
unsigned long lastFeed = 0;
bool beltDirty = true;
static int16_t beltX() { return (int16_t)(WORLD_X + BELT_FIRST * TILE); }
static int16_t beltY() { return (int16_t)(WORLD_Y + BELT_ROW * TILE); }
// Draw one tile of belt: a dark base with a stripe every 8 pixels, shifted by
// `phase`. Sliding the stripes is what makes the belt look like it moves.
static void drawBeltTile(uint8_t index) {
int16_t x = (int16_t)(beltX() + index * TILE);
tft.fillRect(x, beltY(), TILE, TILE, BELT);
for (int16_t s = 0; s < TILE; s += 8) {
tft.fillRect((int16_t)(x + ((s + phase) % TILE)), beltY(), 2, TILE, STRIPE);
}
}
static void drawLumpOn(int16_t pos) {
// Draw only the part of the lump that is on the belt.
int16_t x = (int16_t)(beltX() + pos);
tft.fillRect(x, (int16_t)(beltY() + 4), LUMP, LUMP, ORE);
}
static void checkButton();
// Painting the whole belt takes the Uno well over a hundred milliseconds. A
// quick press could come and go in that time, so the button is looked at
// between tiles as well as once per pass round loop().
static void drawBelt() {
for (uint8_t i = 0; i < BELT_TILES; i++) {
drawBeltTile(i);
checkButton();
}
for (uint8_t i = 0; i < MAX_LUMPS; i++) {
if (lump[i] >= 0) {
drawLumpOn(lump[i]);
}
}
}
static void drawEnds() {
// The feeder on the left: a steel hopper with a chute.
tft.fillRect(WORLD_X + 0 * TILE, WORLD_Y + BELT_ROW * TILE, TILE * 2, TILE, STEEL);
tft.fillRect(WORLD_X + 4, WORLD_Y + BELT_ROW * TILE + 4, TILE * 2 - 8, TILE - 8, ORE);
// The bin on the right.
tft.fillRect(WORLD_X + (BELT_FIRST + BELT_TILES) * TILE, WORLD_Y + BELT_ROW * TILE, TILE, TILE, STEEL);
tft.fillRect(WORLD_X + (BELT_FIRST + BELT_TILES) * TILE + 3, WORLD_Y + BELT_ROW * TILE + 3, TILE - 6,
TILE - 6, DARK);
}
static void drawLabels() {
tft.text(8, 176, "SPEED", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(8, 198, "BIN", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(8, 220, "BACK", ILI9341_WHITE, ILI9341_BLACK, 2);
}
static void drawNumbers() {
tft.text(8 + 6 * 12, 176, speed < 0 ? "<" : (speed > 0 ? ">" : " "), ILI9341_CYAN, ILI9341_BLACK, 2);
tft.number(8 + 7 * 12, 176, speed < 0 ? -speed : speed, ILI9341_YELLOW, ILI9341_BLACK, 2, 1);
tft.number(8 + 5 * 12, 198, delivered, ILI9341_YELLOW, ILI9341_BLACK, 2, 4);
tft.number(8 + 5 * 12, 220, returned, ILI9341_YELLOW, ILI9341_BLACK, 2, 4);
}
static void report() {
Serial.print("speed ");
Serial.print(speed);
Serial.print(" bin ");
Serial.print(delivered);
Serial.print(" back ");
Serial.println(returned);
}
// The knob to a speed. Below the dead zone the belt runs backward, above it
// forward, and inside it the belt stands still.
static int8_t speedFromKnob(int raw) {
int offset = raw - 512;
if (offset > -DEAD_ZONE && offset < DEAD_ZONE) {
return 0;
}
int magnitude = (offset < 0 ? -offset : offset) - DEAD_ZONE; // 0..472
int s = 1 + (magnitude * (MAX_SPEED - 1)) / (512 - DEAD_ZONE); // 1..MAX_SPEED
if (s > MAX_SPEED) {
s = MAX_SPEED;
}
return (int8_t)(offset < 0 ? -s : s);
}
// Put a new lump on whichever end the belt is carrying away from.
static void feed() {
if (!feederOn || speed == 0) {
return;
}
int16_t start = speed > 0 ? 0 : (int16_t)(BELT_PX - LUMP);
// Leave a gap: do not drop a lump on top of another.
for (uint8_t i = 0; i < MAX_LUMPS; i++) {
if (lump[i] >= 0 && lump[i] > start - LUMP * 2 && lump[i] < start + LUMP * 2) {
return;
}
}
for (uint8_t i = 0; i < MAX_LUMPS; i++) {
if (lump[i] < 0) {
lump[i] = start;
return;
}
}
}
static void moveLumps(int16_t pixels) {
for (uint8_t i = 0; i < MAX_LUMPS; i++) {
if (lump[i] < 0) {
continue;
}
lump[i] = (int16_t)(lump[i] + pixels);
if (lump[i] > BELT_PX - LUMP) {
lump[i] = -1;
delivered++;
tone(PIEZO_PIN, 1200, 30);
report();
} else if (lump[i] < 0) {
lump[i] = -1;
returned++;
tone(PIEZO_PIN, 600, 30);
report();
}
}
}
static void checkButton() {
if (pressedOnce(feederButton)) {
feederOn = !feederOn;
digitalWrite(LED_PIN, feederOn ? HIGH : LOW);
report();
}
}
void setup() {
Serial.begin(9600);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, HIGH); // the feeder starts on
for (uint8_t i = 0; i < MAX_LUMPS; i++) {
lump[i] = -1;
}
tft.begin();
tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
drawEnds();
drawLabels();
drawBelt();
drawNumbers();
report();
}
void loop() {
unsigned long now = millis();
checkButton();
if (now - lastTick < 40) {
return;
}
unsigned long elapsed = now - lastTick;
lastTick = now;
speed = speedFromKnob(analogRead(KNOB_PIN));
if (speed != 0) {
// How far did the belt run since the last frame? speed * elapsed / TICK_MS
// pixels, keeping the leftover fraction so slow speeds still add up.
carry += (long)speed * (long)elapsed;
int16_t pixels = (int16_t)(carry / (long)TICK_MS);
carry -= (long)pixels * (long)TICK_MS;
// Slide the stripes the same way the lumps go.
phase = (uint8_t)((phase + 16 + (pixels % 16)) % 16);
if (now - lastFeed >= FEED_MS) {
lastFeed = now;
feed();
}
moveLumps(pixels);
beltDirty = true;
} else {
carry = 0;
}
if (beltDirty) {
drawBelt();
beltDirty = false;
}
if (speed != drawnSpeed) {
drawnSpeed = speed;
drawNumbers();
report();
}
}
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);
digitalWrite(LED_PIN, HIGH); // the feeder starts on
for (uint8_t i = 0; i < MAX_LUMPS; i++) {
lump[i] = -1;
}
tft.begin();
tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
drawEnds();
drawLabels();
drawBelt();
drawNumbers();
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();
checkButton();
if (now - lastTick < 40) {
return;
}
unsigned long elapsed = now - lastTick;
lastTick = now;
speed = speedFromKnob(analogRead(KNOB_PIN));
if (speed != 0) {
// How far did the belt run since the last frame? speed * elapsed / TICK_MS
// pixels, keeping the leftover fraction so slow speeds still add up.
carry += (long)speed * (long)elapsed;
int16_t pixels = (int16_t)(carry / (long)TICK_MS);
carry -= (long)pixels * (long)TICK_MS;
// Slide the stripes the same way the lumps go.
phase = (uint8_t)((phase + 16 + (pixels % 16)) % 16);
if (now - lastFeed >= FEED_MS) {
lastFeed = now;
feed();
}
moveLumps(pixels);
beltDirty = true;
} else {
carry = 0;
// ... 11 more lines in the editorTroubleshooting
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.