Lunar Lander on an Arduino Uno
Land a rocket on the Moon with one button on a 128x64 OLED: thrust against gravity, mind the fuel gauge and the speed, and touch down softly on the pad. The whole build, an Arduino Uno and 3 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
- Displays
- Games
- Sound
- Making sound with a buzzer
- Difficulty: Advanced
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 5
How it works
Land a rocket on the Moon with one button on a 128x64 OLED: thrust against gravity, mind the fuel gauge and the speed, and touch down softly on the pad.
Arduino Uno R3 runs the sketch below. The circuit around it: OLED display, 128x64, Arcade button, Buzzer. pinMode sets BUTTON_PIN as an input with its pull-up on. digitalWrite drives BUZZER_PIN HIGH. digitalRead reads whether BUTTON_PIN is HIGH or LOW. delay waits ms milliseconds. Serial.begin opens the serial port at 115200 baud.
Parts list
5 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 × OLED display, 128x64In Fight Stick Kit, Pocket Pet Kit, Slot Machine Kit.
- 1 × Arcade buttonIn Claw Machine Kit, Fight Stick Kit, Pinball Table Kit.
- 1 × BuzzerIn Starter Kit, Rocket Flight Computer 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 |
|---|---|
| Buzzer pin 1 | Arduino Uno R3 pin 8 |
| Arcade button pin 2 | Arduino Uno R3 pin 2 |
| OLED display, 128x64 pin VCC | Arduino Uno R3 pin 5V |
| OLED display, 128x64 pin GND | Arduino Uno R3 pin GND |
| Arcade button pin 1 | Arduino Uno R3 pin GND |
| Buzzer pin 2 | Arduino Uno R3 pin GND |
| OLED display, 128x64 pin SDA | Arduino Uno R3 pin A4 |
| OLED display, 128x64 pin SCL | Arduino Uno R3 pin A5 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Lunar Lander: set a rocket down on the pad with one button and a tank of fuel.
//
// Wiring, board to modules:
// OLED SCL -> A5 SDA -> A4 VCC -> 5 V GND -> GND
// THRUST button -> pin 2 and GND (the chip's pull-up holds the pin high)
// buzzer + -> pin 8 buzzer - -> GND
//
// # How to play
//
// Hold the button and the engine fires, pushing the rocket up. Let go and
// gravity pulls it down. Touch the pad slower than the safe speed and you
// land; any faster and you crash. Fuel only burns while the engine is on, so
// the trick is to fall, burn late, and arrive gently with fuel to spare.
//
// Three worlds, each with stronger gravity than the last:
//
// MOON g = 1.6 m/s^2 MARS g = 3.7 m/s^2 VENUS g = 8.9 m/s^2
//
// Land safely and you go on to the next world. A crash starts again from the
// Moon. The score for a landing is 100 per world, plus two for every unit of
// fuel left, plus up to 40 for how softly you touched down.
//
// # The physics, in three lines
//
// Every tick (50 milliseconds) the sketch does exactly this:
//
// velocity = velocity + gravity (gravity always pulls down)
// velocity = velocity - thrust (only while the button is held)
// position = position + velocity
//
// That is all a lander is. Gravity is a constant push, so the fall gets
// faster and faster; the engine is a bigger push the other way. The numbers
// are whole numbers of 1/256 of a pixel, because the Uno has no hardware for
// fractions and whole numbers are quick. Dividing by 256 is the same as
// shifting right by 8, and that is how a position becomes a pixel.
//
// # Drawing only what moved
//
// The screen is a 1 KB picture, so the sketch draws the ground and the stars
// once, and each tick it rubs out the old rocket, draws the new one and
// updates the numbers that changed. The driver sends only the blocks of the
// screen that differ, so a tick costs a few dozen bytes on the bus.
#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"
const uint8_t SCL_PIN = A5;
const uint8_t SDA_PIN = A4;
const uint8_t BUTTON_PIN = 2;
const uint8_t BUZZER_PIN = 8;
SoftI2c bus(SCL_PIN, SDA_PIN, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);
/** One tick of the game, in milliseconds. */
const uint32_t TICK_MS = 50;
/** Everything in the physics is in 1/256 of a pixel (per tick, per tick squared). */
const int32_t ONE = 256;
/** Engine push, in 1/256 pixel per tick per tick. Stronger than any gravity here. */
const int32_t THRUST = 24;
/** A landing is safe at or below this speed (about 7 pixels a second). */
const int32_t SAFE_SPEED = 90;
/** How many ticks of burn a full tank holds: ten seconds. */
const int16_t TANK = 200;
/** The surface the rocket lands on, and where its feet are measured from. */
const int16_t PAD_Y = 58;
const int16_t PAD_W = 28;
struct World {
const char *name;
const char *g; // as printed, in metres per second squared
int32_t gravity; // 1/256 pixel per tick per tick
};
const World WORLDS[] = {
{"MOON", "1.6", 3},
{"MARS", "3.7", 7},
{"VENUS", "8.9", 16},
};
const uint8_t WORLD_COUNT = 3;
enum Phase : uint8_t { TITLE, FLYING, LANDED, CRASHED };
Phase phase = TITLE;
uint8_t level = 0;
int32_t y = 0; // height of the rocket's centre, 1/256 pixel
int32_t vy = 0; // downward speed, 1/256 pixel per tick
int16_t fuel = TANK;
int16_t padX = 64; // centre of the pad, which is also where the rocket falls
long score = 0;
long best = 0;
bool burning = false;
uint32_t lastTick = 0;
uint32_t tickCount = 0;
uint16_t lfsr = 0xBEEFu;
bool wasDown = false;
// The rocket: 7 wide, 5 tall. Each row is one byte, the top bit the left pixel.
const uint8_t SPRITE[5] = {0x38, 0x7C, 0xFE, 0x44, 0xC6};
const int16_t SPRITE_W = 7;
const int16_t SPRITE_H = 5;
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 int32_t absval(int32_t v) { return v < 0 ? -v : v; }
/** A short beep on the buzzer. delay() is fine here: the game is paused for it. */
static void beep(uint16_t ms) {
digitalWrite(BUZZER_PIN, HIGH);
delay(ms);
digitalWrite(BUZZER_PIN, LOW);
}
static void drawSprite(int16_t x, int16_t top, bool on) {
for (int16_t row = 0; row < SPRITE_H; row++) {
for (int16_t col = 0; col < SPRITE_W; col++) {
if (SPRITE[row] & (0x80u >> col)) {
oled.setPixel((int16_t)(x - 3 + col), (int16_t)(top + row), on);
}
}
}
}
/** The engine flame: three pixels under the feet, drawn or rubbed out. */
static void drawFlame(int16_t x, int16_t top, bool on) {
oled.setPixel(x, (int16_t)(top + SPRITE_H), on);
oled.setPixel(x, (int16_t)(top + SPRITE_H + 1), on);
oled.setPixel((int16_t)(x - 1), (int16_t)(top + SPRITE_H), on);
oled.setPixel((int16_t)(x + 1), (int16_t)(top + SPRITE_H), on);
}
int16_t shownTop = -100;
bool shownFlame = false;
/** Rub the rocket out of where it was and put it where it is now. */
static void drawRocket() {
int16_t top = (int16_t)((y >> 8) - 2);
if (shownTop > -100) {
drawSprite(padX, shownTop, false);
drawFlame(padX, shownTop, false);
}
drawSprite(padX, top, true);
if (burning && fuel > 0) {
drawFlame(padX, top, true);
}
shownTop = top;
shownFlame = burning;
}
/** The numbers along the top: a fuel bar, the speed, the score. */
int16_t shownFuel = -1;
int16_t shownSpeed = -1;
long shownScore = -1;
static void drawHud(bool all) {
if (all) {
shownFuel = shownSpeed = -1;
shownScore = -1;
oled.text(0, 0, "F", 1, true, false);
oled.rect(7, 0, 42, 7, true);
oled.text(54, 0, "V", 1, true, false);
oled.text(86, 0, "S", 1, true, false);
}
int16_t bar = (int16_t)((int32_t)fuel * 38 / TANK);
if (bar != shownFuel) {
oled.fillRect(9, 2, 38, 3, false);
oled.fillRect(9, 2, bar, 3, true);
shownFuel = bar;
}
// Speed in pixels a second: 1/256 pixel a tick times 20 ticks, over 256.
int16_t speed = (int16_t)(vy * 20 / ONE);
if (speed != shownSpeed) {
oled.fillRect(62, 0, 22, 7, false);
oled.number(62, 0, speed, 1, true, false);
shownSpeed = speed;
}
if (score != shownScore) {
oled.fillRect(94, 0, 34, 7, false);
oled.number(94, 0, score, 1, true, false);
shownScore = score;
}
}
/** Mountains either side of the pad, a random walk that never leaves the screen. */
static void drawGround() {
int16_t left = (int16_t)(padX - PAD_W / 2);
int16_t right = (int16_t)(padX + PAD_W / 2);
oled.hLine(left, PAD_Y, PAD_W, true);
oled.hLine(left, (int16_t)(PAD_Y + 1), PAD_W, true);
// Two little legs under the pad so it reads as a platform.
oled.vLine(left, (int16_t)(PAD_Y + 2), 5, true);
oled.vLine((int16_t)(right - 1), (int16_t)(PAD_Y + 2), 5, true);
int16_t h = (int16_t)(PAD_Y + 1);
for (int16_t x = (int16_t)(left - 1); x >= 0; x--) {
h = (int16_t)(h + (int16_t)(rnd() % 5) - 2);
if (h < 46) h = 46;
if (h > 62) h = 62;
oled.setPixel(x, h, true);
oled.vLine(x, h, (int16_t)(64 - h), (x & 3) == 0);
}
h = (int16_t)(PAD_Y + 1);
for (int16_t x = right; x < 128; x++) {
h = (int16_t)(h + (int16_t)(rnd() % 5) - 2);
if (h < 46) h = 46;
if (h > 62) h = 62;
oled.setPixel(x, h, true);
oled.vLine(x, h, (int16_t)(64 - h), (x & 3) == 0);
}
}
static void drawStars() {
for (uint8_t i = 0; i < 18; i++) {
int16_t sx = (int16_t)(rnd() % 128);
int16_t sy = (int16_t)(10 + rnd() % 34);
// Keep the stars out of the rocket's column, so one is never mistaken for it.
if (absval(sx - padX) > 6) {
oled.setPixel(sx, sy, true);
}
}
}
static void centred(int16_t row, const char *s) {
int16_t w = 0;
for (const char *p = s; *p != '\0'; p++) w += 6;
oled.text((int16_t)((128 - w) / 2), row, s, 1, true, false);
}
static void showTitle() {
oled.clear();
centred(8, "LUNAR LANDER");
centred(24, "HOLD THE BUTTON TO BURN");
centred(34, "LAND SLOWLY ON THE PAD");
centred(50, "PRESS TO START");
oled.display();
}
static void startLevel() {
oled.clear();
padX = (int16_t)(30 + rnd() % 68);
y = (int32_t)14 << 8;
vy = 0;
fuel = TANK;
burning = false;
shownTop = -100;
drawStars();
drawGround();
drawHud(true);
// The world's name and gravity float in the sky for the first moments.
char line[24];
uint8_t n = 0;
for (const char *p = WORLDS[level].name; *p != '\0'; p++) line[n++] = *p;
line[n++] = ' ';
line[n++] = 'g';
line[n++] = ' ';
for (const char *p = WORLDS[level].g; *p != '\0'; p++) line[n++] = *p;
line[n] = '\0';
int16_t w = (int16_t)(n * 6);
oled.text((int16_t)((128 - w) / 2), 26, line, 1, true, false);
drawRocket();
oled.display();
Serial.print("world ");
Serial.println(WORLDS[level].name);
phase = FLYING;
tickCount = 0;
lastTick = millis();
}
static void showResult(bool ok, long points) {
centred(20, ok ? "TOUCHDOWN" : "CRASH");
if (ok) {
oled.text(40, 30, "+", 1, true, false);
oled.number(46, 30, points, 1, true, false);
} else {
centred(30, "BOOM");
}
oled.display();
}
static void finish() {
int32_t speed = absval(vy);
burning = false;
if (speed <= SAFE_SPEED) {
long soft = (long)((SAFE_SPEED - speed) * 40 / SAFE_SPEED);
long points = 100L * (level + 1) + 2L * fuel + soft;
score += points;
if (score > best) best = score;
phase = LANDED;
Serial.print("LANDED score ");
Serial.println(score);
drawRocket();
drawHud(false);
showResult(true, points);
beep(80);
delay(80);
beep(80);
delay(80);
beep(240);
} else {
phase = CRASHED;
Serial.print("CRASH score ");
Serial.println(score);
// The crash leaves a few sparks where the rocket hit.
for (uint8_t i = 0; i < 14; i++) {
oled.setPixel((int16_t)(padX - 8 + rnd() % 17), (int16_t)(PAD_Y - 5 + rnd() % 5), true);
}
drawHud(false);
showResult(false, 0);
beep(600);
}
}
static void tick() {
tickCount++;
bool down = digitalRead(BUTTON_PIN) == LOW;
burning = down && fuel > 0;
// The two lines the whole game is made of.
vy += WORLDS[level].gravity;
if (burning) {
vy -= THRUST;
fuel--;
}
y += vy;
// The ceiling: the top of the screen stops the rocket dead.
if (y < ((int32_t)10 << 8)) {
y = (int32_t)10 << 8;
if (vy < 0) vy = 0;
}
// Feet are two pixels below the centre; the pad is PAD_Y.
if ((y >> 8) + 2 + 1 >= PAD_Y) {
y = (int32_t)(PAD_Y - 3) << 8;
drawRocket();
finish();
return;
}
// The sky text is gone after a second.
if (tickCount == 24) {
oled.fillRect(0, 24, 128, 10, false);
}
drawRocket();
drawHud(false);
oled.display();
// The engine rumbles in the buzzer a tick on, a tick off, and a warning chirps when fuel is low.
if (burning) {
digitalWrite(BUZZER_PIN, (tickCount & 1u) ? HIGH : LOW);
} else if (fuel < TANK / 5 && fuel > 0 && (tickCount % 10u) == 0u) {
digitalWrite(BUZZER_PIN, HIGH);
} else {
digitalWrite(BUZZER_PIN, LOW);
}
if ((tickCount % 2u) == 0u) {
// Flight recorder: height above the pad, speed, fuel.
Serial.print("alt ");
Serial.print((int)(PAD_Y - 3 - (y >> 8)));
Serial.print(" v ");
Serial.print((int)vy);
Serial.print(" fuel ");
Serial.println(fuel);
}
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: Lunar Lander");
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
oled.begin();
showTitle();
}
void loop() {
bool down = digitalRead(BUTTON_PIN) == LOW;
bool pressed = down && !wasDown;
wasDown = down;
if (phase == FLYING) {
uint32_t now = millis();
if ((uint32_t)(now - lastTick) >= TICK_MS) {
lastTick += TICK_MS;
tick();
}
return;
}
if (pressed) {
lfsr ^= (uint16_t)micros();
if (lfsr == 0u) lfsr = 0xBEEFu;
if (phase == TITLE) {
score = 0;
level = 0;
} else if (phase == LANDED) {
level = (uint8_t)((level + 1) % WORLD_COUNT);
} else {
score = 0;
level = 0;
}
startLevel();
// Wait for the finger to come off, so the first tick is not a burn.
while (digitalRead(BUTTON_PIN) == LOW) {
delay(5);
}
wasDown = false;
lastTick = millis();
}
delay(5);
}
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: Lunar Lander");
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
oled.begin();
showTitle();
}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() {
bool down = digitalRead(BUTTON_PIN) == LOW;
bool pressed = down && !wasDown;
wasDown = down;
if (phase == FLYING) {
uint32_t now = millis();
if ((uint32_t)(now - lastTick) >= TICK_MS) {
lastTick += TICK_MS;
tick();
}
return;
}
if (pressed) {
lfsr ^= (uint16_t)micros();
if (lfsr == 0u) lfsr = 0xBEEFu;
if (phase == TITLE) {
score = 0;
level = 0;
} else if (phase == LANDED) {
level = (uint8_t)((level + 1) % WORLD_COUNT);
} else {
score = 0;
level = 0;
}
startLevel();
// Wait for the finger to come off, so the first tick is not a burn.
// ... 8 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The OLED stays blank. OLED display
Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address. - Text never shows up even though the code runs. OLED display
Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames. - The button reads pressed all the time, or never changes, when wired to a pin. Arcade button
Use the COM and NO terminals of the microswitch. Wire COM to ground, NO to the pin, and use INPUT_PULLUP. NC is the opposite and reads pressed when released. - One press counts as several presses in your code. Arcade button
Microswitch contacts bounce for a few milliseconds. Ignore changes for 10 to 20 ms after the first one, or react only to the edge and wait before checking again. - The buzzer makes a constant tone, or you cannot change its pitch. Buzzer
An active buzzer has its own oscillator, so it only beeps on and off. A passive buzzer needs tone() to set pitch. Check which type you have. - The buzzer is silent or very quiet. Buzzer
Check polarity: the plus mark goes to the pin or supply. For loud buzzers, drive them through a transistor instead of straight from a pin. - Upload fails or nothing runs after wiring pins 0 and 1 to a module. Arduino Uno R3
Pins 0 and 1 are the USB serial port. Move the module to other pins, or unplug it while uploading. Also check the right board is selected. - Pins misbehave or the board resets when you power motors or servos from it. Arduino Uno R3
Each I/O pin gives about 20 mA, 40 mA max. Power motors and servos from their own supply and join its ground to the Uno's GND.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.