Factory 4: Smelter, on an Arduino Uno
A furnace turns three ore into one plate on a recipe timer. Feed it ore and fuel with two buttons and set the heat: hotter is faster but burns more fuel per plate. The whole build, an Arduino Uno and 7 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 35 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
- Reading a button
- Choosing and reading a resistor
- Difficulty: Intermediate
- Time: About 35 minutes
- Board: Arduino Uno R3
- Parts: 12
How it works
A furnace turns three ore into one plate on a recipe timer. Feed it ore and fuel with two buttons and set the heat: hotter is faster but burns more fuel per plate.
Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Pushbutton, Resistor, 220 Ω, LED, red, Piezo speaker, Potentiometer. pinMode sets ORE_BUTTON as an input with its pull-up on. digitalWrite drives LED_PIN LOW. digitalRead reads whether b.pin is HIGH or LOW. analogRead measures the voltage on HEAT_KNOB as a number. Serial.begin opens the serial port at 9600 baud.
Parts list
12 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.
- 2 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, redIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine 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 |
| Pushbutton (2) pin 1a | Arduino Uno R3 pin 4 |
| Resistor, 220 Ω pin 1 | Arduino Uno R3 pin 3 |
| Pushbutton (1) 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 |
| Pushbutton (1) pin 2a | Ground |
| Pushbutton (2) pin 2a | Ground |
| LED, red pin A | Resistor, 220 Ω pin 2 |
| LED, red pin C | Ground |
| Piezo speaker pin 2 | 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 4: Smelter. Three ore and some fuel make one plate.
//
// Press ORE to put a lump in the chest and FUEL to shovel fuel into the
// furnace. When the chest holds three ore the furnace takes them and starts a
// recipe: it needs time, and it needs fuel the whole time. Turn the HEAT knob
// to go from 1 (slow and cheap) to 3 (fast and thirsty).
//
// 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
// ORE button: pin 2 to GND FUEL button: pin 4 to GND
// HEAT knob (10k pot): middle leg -> A0, outer legs to 5 V and GND
// Fire LED: pin 3 through 220 ohm to GND Piezo: pin 6 to GND
//
// # What this teaches
//
// 1. A recipe is data plus a timer. "3 ore in, 1 plate out, 4000 ms of work" is
// all the furnace knows. Change those numbers and you have a different
// machine without touching the logic.
// 2. A state machine with a stall. The furnace is IDLE (waiting for ore),
// SMELTING, or STALLED (out of fuel in the middle of a recipe). A stall
// keeps the work done so far and carries on when fuel arrives. That is how
// real machines behave and it avoids throwing ore away.
// 3. Trade-offs. Heat multiplies the speed, but fuel burns with the square of
// the heat. Heat 1 makes a plate for 4 fuel, heat 2 for 8, heat 3 for 12.
// The fast setting is not the efficient one, and the numbers on the screen
// let you check it.
// 4. Debouncing. Both buttons wait for the pin to settle before a press counts,
// so one push is one lump of ore or one shovel of fuel.
// 5. Work in elapsed time. Progress and fuel both change by "milliseconds that
// passed times a rate", so they stay correct however long a frame takes.
#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 ORE_BUTTON = 2;
const uint8_t LED_PIN = 3;
const uint8_t FUEL_BUTTON = 4;
const uint8_t PIEZO_PIN = 6;
const uint8_t HEAT_KNOB = A0;
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 BRICK = 0x8a22;
const uint16_t BRICK_DARK = 0x4101;
const uint16_t DARK = 0x10a2;
const uint16_t ORE = 0xfc60;
const uint16_t PLATE = 0xc618;
const uint16_t FIRE_A = ILI9341_ORANGE;
const uint16_t FIRE_B = ILI9341_YELLOW;
const uint16_t FUEL = 0x7bef;
// The recipe.
const uint8_t ORE_PER_PLATE = 3;
const unsigned long RECIPE_MS = 4000; // work needed at heat 1
const long FUEL_MAX = 40; // units the furnace holds
const long FUEL_PER_PRESS = 12;
const uint8_t CHEST_SLOTS = 6;
const uint8_t TRAY_SLOTS = 6;
// 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 Furnace { IDLE, SMELTING, STALLED };
Furnace state = IDLE;
uint8_t ore = 0;
unsigned int plates = 0;
long fuelMilli = 0; // thousandths of a fuel unit
unsigned long workDone = 0; // ms of recipe work, at heat 1 speed
uint8_t heat = 1; // 1..3
bool flameFrame = false;
unsigned long lastFlame = 0;
unsigned long lastFrame = 0;
Button oreButton = {ORE_BUTTON, false, false, 0};
Button fuelButton = {FUEL_BUTTON, false, false, 0};
// What the screen last showed, so only changes are drawn.
uint8_t shownOre = 255, shownHeat = 255, shownBar = 255, shownFuelBar = 255;
long shownFuel = -1;
unsigned int shownPlates = 65535;
long shownOreNumber = -1;
long shownPlateNumber = -1;
int8_t shownState = -1;
static int16_t tileX(uint8_t c) { return (int16_t)(WORLD_X + c * TILE); }
static int16_t tileY(uint8_t r) { return (int16_t)(WORLD_Y + r * TILE); }
static void slot(uint8_t col, uint8_t row, uint16_t outer, uint16_t inner) {
tft.fillRect(tileX(col), tileY(row), TILE, TILE, outer);
tft.fillRect((int16_t)(tileX(col) + 3), (int16_t)(tileY(row) + 3), TILE - 6, TILE - 6, inner);
}
// Chest and tray slots are drawn one at a time. When the count changes only the
// slots between the old count and the new one are repainted, which keeps each
// job short.
static void drawChestSlot(uint8_t i) {
slot((uint8_t)(1 + (i & 1)), (uint8_t)(3 + (i >> 1)), STEEL, i < ore ? ORE : DARK);
}
static void drawTraySlot(uint8_t i) {
slot((uint8_t)(11 + (i & 1)), (uint8_t)(3 + (i >> 1)), STEEL, i < plates ? PLATE : DARK);
}
static void drawChest(uint8_t from, uint8_t to) {
for (uint8_t i = from; i < to && i < CHEST_SLOTS; i++) {
drawChestSlot(i);
}
}
static void drawTray(uint8_t from, uint8_t to) {
for (uint8_t i = from; i < to && i < TRAY_SLOTS; i++) {
drawTraySlot(i);
}
}
// The furnace is a 2 x 3 block of brick with a window in the middle. The block
// is painted once; after that only the window changes, because it shows the
// fire: orange and yellow when working, a dull red when it has stalled and
// black when it is idle.
static void drawFurnace() {
tft.fillRect(tileX(6), tileY(3), 2 * TILE, 3 * TILE, BRICK);
tft.rect(tileX(6), tileY(3), 2 * TILE, 3 * TILE, BRICK_DARK, 2);
}
static void drawFire() {
int16_t wx = (int16_t)(tileX(6) + 6);
int16_t wy = (int16_t)(tileY(3) + 16);
tft.fillRect(wx, wy, 20, 16, DARK);
if (state == SMELTING) {
tft.fillRect((int16_t)(wx + 2), (int16_t)(wy + (flameFrame ? 2 : 6)), 16, (int16_t)(flameFrame ? 12 : 8), FIRE_A);
tft.fillRect((int16_t)(wx + 6), (int16_t)(wy + (flameFrame ? 6 : 8)), 8, (int16_t)(flameFrame ? 8 : 6), FIRE_B);
} else if (state == STALLED) {
tft.fillRect((int16_t)(wx + 4), (int16_t)(wy + 10), 12, 4, ILI9341_RED);
}
}
// A bar of `value` out of `full`, `width` pixels wide at (x, y).
static void bar(int16_t x, int16_t y, int16_t width, long value, long full, uint16_t colour) {
int16_t filled = (int16_t)((value * width) / full);
tft.fillRect(x, y, width, 8, DARK);
if (filled > 0) {
tft.fillRect(x, y, filled, 8, colour);
}
}
static void drawLabels() {
tft.text(8, 176, "ORE", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(96, 176, "HEAT", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(8, 198, "FUEL", ILI9341_WHITE, ILI9341_BLACK, 2);
tft.text(104, 198, "PLATES", ILI9341_WHITE, ILI9341_BLACK, 2);
}
static const char *stateName() {
return state == SMELTING ? "SMELTING" : (state == STALLED ? "NO FUEL " : "WAITING ");
}
static void report() {
Serial.print("ore ");
Serial.print(ore);
Serial.print(" fuel ");
Serial.print(fuelMilli / 1000);
Serial.print(" plates ");
Serial.print(plates);
Serial.print(" ");
Serial.println(stateName());
}
static void setState(Furnace next) {
if (next == state) {
return;
}
state = next;
digitalWrite(LED_PIN, next == SMELTING ? HIGH : LOW);
drawFire();
report();
}
// Redraw whatever changed, but only ONE thing per call. Painting is slow, and
// while the Uno is painting it is not looking at the buttons. Doing a single
// small job and then going back round loop() keeps a quick tap from falling
// into a gap. The next call finds the next thing that is out of date.
static void refresh() {
if (ore != shownOre) {
uint8_t old = shownOre == 255 ? 0 : shownOre;
shownOre = ore;
drawChest(old < ore ? old : ore, old < ore ? ore : old);
} else if (heat != shownHeat) {
shownHeat = heat;
tft.number(96 + 5 * 12, 176, heat, ILI9341_CYAN, ILI9341_BLACK, 2, 1);
} else if (fuelMilli / 1000 != shownFuel) {
shownFuel = fuelMilli / 1000;
tft.number(8 + 5 * 12, 198, shownFuel, ILI9341_YELLOW, ILI9341_BLACK, 2, 2);
} else if (plates != shownPlates) {
uint8_t old = shownPlates == 65535 ? 0 : (shownPlates > 255 ? 255 : (uint8_t)shownPlates);
shownPlates = plates;
uint8_t now = plates > 255 ? 255 : (uint8_t)plates;
drawTray(old < now ? old : now, old < now ? now : old);
} else if ((int8_t)state != shownState) {
shownState = (int8_t)state;
tft.text(8, 220, stateName(), state == SMELTING ? ILI9341_GREEN : (state == STALLED ? ILI9341_RED : ILI9341_WHITE),
ILI9341_BLACK, 2);
} else if (shownOreNumber != ore) {
shownOreNumber = ore;
tft.number(8 + 4 * 12, 176, ore, ILI9341_YELLOW, ILI9341_BLACK, 2, 1);
} else if (shownPlateNumber != plates) {
shownPlateNumber = plates;
tft.number(104 + 7 * 12, 198, plates, ILI9341_YELLOW, ILI9341_BLACK, 2, 3);
} else {
uint8_t progressBar = (uint8_t)((workDone * 16UL) / RECIPE_MS);
uint8_t fuelBar = (uint8_t)((fuelMilli * 16L) / (FUEL_MAX * 1000L));
if (progressBar != shownBar) {
shownBar = progressBar;
bar(tileX(5), (int16_t)(tileY(2) + 4), 4 * TILE, progressBar, 16, ILI9341_GREEN);
} else if (fuelBar != shownFuelBar) {
shownFuelBar = fuelBar;
bar(tileX(5), (int16_t)(tileY(6) + 4), 4 * TILE, fuelBar, 16, FUEL);
}
}
}
void setup() {
Serial.begin(9600);
pinMode(ORE_BUTTON, INPUT_PULLUP);
pinMode(FUEL_BUTTON, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
tft.begin();
tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
drawLabels();
drawFurnace();
drawChest(0, CHEST_SLOTS);
drawTray(0, TRAY_SLOTS);
drawFire();
for (uint8_t i = 0; i < 12; i++) {
refresh();
}
report();
lastFrame = lastFlame = millis();
}
void loop() {
unsigned long now = millis();
unsigned long elapsed = now - lastFrame;
lastFrame = now;
// Buttons: one press, one action.
if (pressedOnce(oreButton) && ore < CHEST_SLOTS) {
ore++;
tone(PIEZO_PIN, 700, 30);
report();
}
if (pressedOnce(fuelButton)) {
fuelMilli += FUEL_PER_PRESS * 1000L;
if (fuelMilli > FUEL_MAX * 1000L) {
fuelMilli = FUEL_MAX * 1000L;
}
tone(PIEZO_PIN, 500, 30);
report();
}
// The heat knob: 0..1023 becomes 1, 2 or 3.
heat = (uint8_t)(1 + (analogRead(HEAT_KNOB) * 3L) / 1024L);
if (heat > 3) {
heat = 3;
}
// The furnace's state machine.
switch (state) {
case IDLE:
if (ore >= ORE_PER_PLATE) {
ore -= ORE_PER_PLATE; // the recipe takes its ore up front
workDone = 0;
setState(fuelMilli > 0 ? SMELTING : STALLED);
}
break;
case STALLED:
if (fuelMilli > 0) {
setState(SMELTING);
}
break;
case SMELTING: {
// Heat multiplies the work done per millisecond, and the fuel burned per
// millisecond goes with heat squared.
workDone += elapsed * heat;
fuelMilli -= (long)elapsed * heat * heat;
if (fuelMilli <= 0) {
fuelMilli = 0;
setState(STALLED);
}
if (workDone >= RECIPE_MS) {
workDone = 0;
plates++;
tone(PIEZO_PIN, 1200, 60);
state = IDLE; // the next frame decides what comes after
digitalWrite(LED_PIN, LOW);
drawFire();
report();
}
break;
}
}
// Flicker the fire.
if (state == SMELTING && now - lastFlame >= 180) {
lastFlame = now;
flameFrame = !flameFrame;
drawFire();
}
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(ORE_BUTTON, INPUT_PULLUP);
pinMode(FUEL_BUTTON, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
tft.begin();
tft.rect(WORLD_X - 3, WORLD_Y - 3, 14 * TILE + 6, 10 * TILE + 6, 0x4a49, 2);
drawLabels();
drawFurnace();
drawChest(0, CHEST_SLOTS);
drawTray(0, TRAY_SLOTS);
drawFire();
for (uint8_t i = 0; i < 12; i++) {
refresh();
}
report();
lastFrame = lastFlame = millis();
}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();
unsigned long elapsed = now - lastFrame;
lastFrame = now;
// Buttons: one press, one action.
if (pressedOnce(oreButton) && ore < CHEST_SLOTS) {
ore++;
tone(PIEZO_PIN, 700, 30);
report();
}
if (pressedOnce(fuelButton)) {
fuelMilli += FUEL_PER_PRESS * 1000L;
if (fuelMilli > FUEL_MAX * 1000L) {
fuelMilli = FUEL_MAX * 1000L;
}
tone(PIEZO_PIN, 500, 30);
report();
}
// The heat knob: 0..1023 becomes 1, 2 or 3.
heat = (uint8_t)(1 + (analogRead(HEAT_KNOB) * 3L) / 1024L);
if (heat > 3) {
heat = 3;
}
// The furnace's state machine.
switch (state) {
// ... 42 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. - The input reads random HIGH and LOW when the button is not pressed. Pushbutton
A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW. - The button seems always pressed, or never changes. Pushbutton
A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected. - 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.