Tilt sand on an Arduino Uno
A tray of glowing sand on a 16x8 LED matrix: tip an MPU6050 and the grains pour downhill, pile against the edge and settle. 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 30 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.
What you'll learn
- Lights
- Displays
- Sensors
- Choosing and reading a resistor
- Reading a button
- Difficulty: Advanced
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 7
How it works
A tray of glowing sand on a 16x8 LED matrix: tip an MPU6050 and the grains pour downhill, pile against the edge and settle.
Arduino Uno R3 runs the sketch below. The circuit around it: Addressable RGB LEDs, Accelerometer and gyroscope, MPU6050, Resistor, 330 Ω, Pushbutton. pinMode sets POUR_PIN as an input with its pull-up on. digitalRead reads whether CLEAR_PIN is HIGH or LOW. delay waits 2 milliseconds. Serial.begin opens the serial port at 115200 baud. millis reads the time since power-up, so the sketch can time things without stopping.
Parts list
7 parts, plus the jumper wires. They retail for about $38; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × Addressable RGB LEDsIn Claw Machine Kit, Halloween Prop Kit, Christmas Lights Kit.
- 1 × Accelerometer and gyroscope, MPU6050In Desk Buddy Kit, Laser Sword Kit, Escape Box Kit.
- 1 × Resistor, 330 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 2 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
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 |
|---|---|
| Resistor, 330 Ω pin 1 | Arduino Uno R3 pin 6 |
| Pushbutton (2) pin 1a | Arduino Uno R3 pin 3 |
| Pushbutton (1) pin 1a | Arduino Uno R3 pin 2 |
| Addressable RGB LEDs pin VDD | Arduino Uno R3 pin 5V |
| Accelerometer and gyroscope, MPU6050 pin VCC | Arduino Uno R3 pin 5V |
| Addressable RGB LEDs pin VSS | Arduino Uno R3 pin GND |
| Accelerometer and gyroscope, MPU6050 pin GND | Arduino Uno R3 pin GND |
| Pushbutton (1) pin 2a | Arduino Uno R3 pin GND |
| Pushbutton (2) pin 2a | Arduino Uno R3 pin GND |
| Accelerometer and gyroscope, MPU6050 pin SDA | Arduino Uno R3 pin A4 |
| Accelerometer and gyroscope, MPU6050 pin SCL | Arduino Uno R3 pin A5 |
| Resistor, 330 Ω pin 2 | Addressable RGB LEDs pin DIN |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Tilt sand: a 16x8 panel of RGB LEDs is a tray of glowing sand, and an
// MPU6050 tells it which way is downhill.
//
// WS2812 matrix DIN -> pin 6 (through a 330 ohm resistor) VDD -> 5 V VSS -> GND
// MPU6050 SDA -> A4 SCL -> A5 VCC -> 5 V GND -> GND
// POUR button pin 2 and GND: hold it to pour more sand in
// CLEAR button pin 3 and GND: tip the tray out
//
// # What it does
//
// Each lit LED is one grain. The board starts with a heap of grains in the
// middle. Lay the sensor flat and nothing happens. Tip it and every grain
// slides one step a moment towards the low side, piles against the edge and
// settles. Tip the other way and the pile pours back across.
//
// # Which way is downhill
//
// An accelerometer measures the pull of gravity along its own two axes. Flat
// on a table both are near zero, because gravity points straight through the
// board. Tip it and one axis picks up a share of the pull, which is the sine
// of the tilt: a gentle tip gives a small number, a steep one a big number.
// The sensor reports 16384 counts for one full g.
//
// So the two numbers are a little arrow pointing downhill. The sketch turns
// the arrow into a step for each grain: it decides whether downhill is left,
// right, up or down on the panel, or a diagonal between them.
//
// # How a grain moves
//
// Once every 50 milliseconds, each grain looks one cell downhill:
//
// free it moves there
// blocked it tries the two cells beside that one (a slide), in a
// random order so a pile does not lean to one side
//
// A grain that cannot move stays put, and that is how a heap forms with
// sloping sides, the same way real sand has an angle of repose.
//
// To keep grains from queueing up on top of each other, the sketch moves the
// ones furthest downhill first, and it never moves one grain twice in a step.
//
// # Colours
//
// The panel is wired in a zigzag: row 0 left to right, row 1 right to left,
// and so on. index() does that arithmetic so the rest of the sketch can think
// in x and y. Each grain gets one of a few warm shades, chosen from its
// position, so the sand glitters as it runs.
#include <Wire.h>
#include <Adafruit_NeoPixel.h>
const uint8_t PANEL_PIN = 6;
const uint8_t POUR_PIN = 2;
const uint8_t CLEAR_PIN = 3;
const uint8_t MPU = 0x68;
const int8_t W = 16;
const int8_t H = 8;
const uint16_t LEDS = 128;
Adafruit_NeoPixel panel(LEDS, PANEL_PIN, NEO_GRB + NEO_KHZ800);
/** The grains: one 16-bit number per row, bit x set when there is a grain at (x, row). */
uint16_t sand[H];
uint16_t moved[H];
uint16_t lfsr = 0xACE1u;
int16_t ax = 0, ay = 0;
uint32_t lastStep = 0;
uint32_t lastReport = 0;
int16_t lastSx = 0, lastSy = 0;
bool dirty = true;
/** Counts of acceleration (16384 is 1 g) below which the tray counts as level: about 7 degrees. */
const int16_t LEVEL = 2000;
static uint8_t rnd() {
uint16_t bit = (uint16_t)(((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1u);
lfsr = (uint16_t)((lfsr >> 1) | (bit << 15));
return (uint8_t)lfsr;
}
static bool has(int8_t x, int8_t y) { return (sand[y] >> x) & 1u; }
static void put(int8_t x, int8_t y) { sand[y] |= (uint16_t)1 << x; }
static void take(int8_t x, int8_t y) { sand[y] &= (uint16_t)~((uint16_t)1 << x); }
static bool free_(int8_t x, int8_t y) { return x >= 0 && x < W && y >= 0 && y < H && !has(x, y); }
static int grains() {
int n = 0;
for (int8_t y = 0; y < H; y++) {
for (int8_t x = 0; x < W; x++) n += has(x, y) ? 1 : 0;
}
return n;
}
/** The LED number of the cell at (x, y) on a zigzag-wired panel. */
static uint16_t index(int8_t x, int8_t y) {
return (uint16_t)(y * W + ((y & 1) ? (W - 1 - x) : x));
}
static void draw() {
for (int8_t y = 0; y < H; y++) {
for (int8_t x = 0; x < W; x++) {
if (has(x, y)) {
// Three warm shades picked from the position: sand with a glint.
uint8_t shade = (uint8_t)((x * 5 + y * 3) % 3);
uint8_t r = shade == 0 ? 60 : (shade == 1 ? 48 : 36);
uint8_t g = shade == 0 ? 36 : (shade == 1 ? 26 : 18);
panel.setPixelColor(index(x, y), r, g, shade == 2 ? 4 : 0);
} else {
panel.setPixelColor(index(x, y), 0, 0, 0);
}
}
}
panel.show();
}
/** Read the accelerometer: x and y, signed, 16384 counts to the g. */
static bool readTilt() {
Wire.beginTransmission(MPU);
Wire.write(0x3B);
Wire.endTransmission();
if (Wire.requestFrom(MPU, 4) != 4) return false;
int16_t x = (int16_t)((Wire.read() << 8) | Wire.read());
int16_t y = (int16_t)((Wire.read() << 8) | Wire.read());
ax = x;
ay = y;
return true;
}
/**
* Downhill as a step on the panel: -1, 0 or 1 across and down. Tipping the
* right edge down makes the sensor's y positive, so panel-x is ay; tipping the
* bottom edge down makes its x negative, so panel-y is minus ax.
*/
static void downhill(int8_t &sx, int8_t &sy) {
int16_t gx = ay;
int16_t gy = (int16_t)(-ax);
int16_t agx = gx < 0 ? -gx : gx;
int16_t agy = gy < 0 ? -gy : gy;
sx = 0;
sy = 0;
if (agx < LEVEL && agy < LEVEL) return; // level: no step
if (agx >= LEVEL) sx = (int8_t)(gx > 0 ? 1 : -1);
if (agy >= LEVEL) sy = (int8_t)(gy > 0 ? 1 : -1);
// A tip mostly along one axis is a straight step, not a diagonal.
if (agy * 5 < agx * 2) sy = 0;
if (agx * 5 < agy * 2) sx = 0;
}
static void tryMove(int8_t x, int8_t y, int8_t sx, int8_t sy) {
int8_t nx = (int8_t)(x + sx);
int8_t ny = (int8_t)(y + sy);
// The cells beside the straight step, to slide into when it is blocked.
int8_t ax1, ay1, ax2, ay2;
if (sx != 0 && sy != 0) {
ax1 = (int8_t)(x + sx); ay1 = y;
ax2 = x; ay2 = (int8_t)(y + sy);
} else if (sx != 0) {
ax1 = nx; ay1 = (int8_t)(y + 1);
ax2 = nx; ay2 = (int8_t)(y - 1);
} else {
ax1 = (int8_t)(x + 1); ay1 = ny;
ax2 = (int8_t)(x - 1); ay2 = ny;
}
if (rnd() & 1u) {
int8_t tx = ax1, ty = ay1;
ax1 = ax2; ay1 = ay2;
ax2 = tx; ay2 = ty;
}
int8_t gx = x, gy = y;
if (free_(nx, ny)) {
gx = nx; gy = ny;
} else if (free_(ax1, ay1)) {
gx = ax1; gy = ay1;
} else if (free_(ax2, ay2)) {
gx = ax2; gy = ay2;
} else {
return;
}
take(x, y);
put(gx, gy);
moved[gy] |= (uint16_t)1 << gx;
dirty = true;
}
/** One step of the whole tray. */
static void step() {
int8_t sx, sy;
downhill(sx, sy);
lastSx = sx;
lastSy = sy;
if (sx == 0 && sy == 0) return;
for (int8_t y = 0; y < H; y++) moved[y] = 0;
// Visit the cells furthest downhill first, so the ones behind them can follow.
for (int8_t j = 0; j < H; j++) {
int8_t y = (int8_t)(sy > 0 ? H - 1 - j : j);
for (int8_t i = 0; i < W; i++) {
int8_t x = (int8_t)(sx > 0 ? W - 1 - i : i);
if (has(x, y) && !((moved[y] >> x) & 1u)) tryMove(x, y, sx, sy);
}
}
}
/** A grain from the uphill edge: the faucet. */
static void pour() {
if (grains() >= 100) return;
int8_t x = (int8_t)(lastSx > 0 ? 0 : (lastSx < 0 ? W - 1 : W / 2));
int8_t y = (int8_t)(lastSy > 0 ? 0 : (lastSy < 0 ? H - 1 : H / 2 - 1));
if (free_(x, y)) {
put(x, y);
dirty = true;
}
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: tilt sand");
pinMode(POUR_PIN, INPUT_PULLUP);
pinMode(CLEAR_PIN, INPUT_PULLUP);
Wire.begin();
// The MPU6050 wakes asleep: write 0 to power management to switch it on.
Wire.beginTransmission(MPU);
Wire.write(0x6B);
Wire.write(0);
Wire.endTransmission();
panel.begin();
// A heap in the middle: 6 columns wide at the bottom, narrowing upwards.
for (int8_t y = 3; y < 7; y++) {
for (int8_t x = (int8_t)(5 + (y - 3) / 2); x < 11 - (y - 3) / 2; x++) put(x, y);
}
draw();
}
void loop() {
uint32_t now = millis();
if ((uint32_t)(now - lastStep) < 50u) {
delay(2);
return;
}
lastStep = now;
if (!readTilt()) return;
if (digitalRead(CLEAR_PIN) == LOW) {
for (int8_t y = 0; y < H; y++) sand[y] = 0;
dirty = true;
}
if (digitalRead(POUR_PIN) == LOW) pour();
step();
if (dirty) {
draw();
dirty = false;
}
if ((uint32_t)(now - lastReport) >= 500u) {
lastReport = now;
Serial.print("tilt ");
Serial.print(ax);
Serial.print(' ');
Serial.print(ay);
Serial.print(" grains ");
Serial.println(grains());
}
}
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(115200);
Serial.println("Mokxi Uno: tilt sand");
pinMode(POUR_PIN, INPUT_PULLUP);
pinMode(CLEAR_PIN, INPUT_PULLUP);
Wire.begin();
// The MPU6050 wakes asleep: write 0 to power management to switch it on.
Wire.beginTransmission(MPU);
Wire.write(0x6B);
Wire.write(0);
Wire.endTransmission();
panel.begin();
// A heap in the middle: 6 columns wide at the bottom, narrowing upwards.
for (int8_t y = 3; y < 7; y++) {
for (int8_t x = (int8_t)(5 + (y - 3) / 2); x < 11 - (y - 3) / 2; x++) put(x, y);
}
draw();
}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() {
uint32_t now = millis();
if ((uint32_t)(now - lastStep) < 50u) {
delay(2);
return;
}
lastStep = now;
if (!readTilt()) return;
if (digitalRead(CLEAR_PIN) == LOW) {
for (int8_t y = 0; y < H; y++) sand[y] = 0;
dirty = true;
}
if (digitalRead(POUR_PIN) == LOW) pour();
step();
if (dirty) {
draw();
dirty = false;
}
if ((uint32_t)(now - lastReport) >= 500u) {
lastReport = now;
Serial.print("tilt ");
Serial.print(ax);
Serial.print(' ');
Serial.print(ay);
Serial.print(" grains ");
Serial.println(grains());
}
}Troubleshooting
The usual mistakes with this circuit, and the fix for each.
- The first LED flickers, shows wrong colors, or the whole strip glitches. Addressable RGB LEDs
Join the grounds of the strip and the controller. Add a 300 to 500 ohm resistor in series with the data line. Put a 1000 uF capacitor across the strip's 5 V and GND. - The strip works at low brightness but dims, turns yellow, or resets when many LEDs go white. Addressable RGB LEDs
Each LED can draw about 60 mA at full white. Use a 5 V supply sized for the count, feed power in at several points, and lower the brightness in code. - The sensor is not found or reads all zeros. Accelerometer and gyroscope
Check SDA and SCL (A4 and A5 on an Uno), power, and ground. The I2C address is 0x68, or 0x69 if AD0 is high. The chip starts in sleep mode, so write 0 to the power register PWR_MGMT_1. - The values look wrong or the angles drift. Accelerometer and gyroscope
Raw readings are counts, not g or degrees per second. Divide by the scale for your range (16384 per g at 2 g). Gyro angles drift, so blend with the accelerometer using a filter. - 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 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.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.