Handheld game console on an ESP32-C3
A pocket console you can build: an ESP32-C3, a color TFT, a D-pad, A, B, Start and Select, chiptune on a piezo and a battery gauge. Three games: Blocks, Runner and Snake. The whole build, an ESP32-C3 and 8 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 1.5 hours to build and run. Runs in your browser. Free, and no account needed.
Build it as the Handheld Console Kit: the parts in numbered bags and a booklet that checks every step. Retails for about $27, free on Mokxi.
What you'll learn
- Reading buttons wired to ground with the internal pull-up
- Redrawing only what changed on a slow display
- Playing music without stopping the program
- Measuring a battery with a two-resistor voltage divider
- Running a game loop at a steady frame rate
- Difficulty: Advanced
- Time: About 1.5 hours
- Board: ESP32-C3-DevKitM-1
- Parts: 10
How it works
An ESP32-C3 drives a 240x320 color screen over four wires (CS, DC, SCK and MOSI on pins 18, 19, 20 and 21). A D-pad module, an A and B pair, and two pushbuttons (Start on pin 2, Select on pin 9) all connect a pin to ground. The chip's own pull-ups read them, so no resistors are needed.
On power-up the sketch shows a MOKXI splash, then a menu with three games: Blocks, Runner and Snake. Up and down pick a game, A or Start plays it, and B turns the sound on and off. In a game, Start pauses and Select returns to the menu.
Redrawing the whole screen would be far too slow, so each game paints only what changed. A piezo on pin 7 plays music and sound effects while the games run. Two 100k resistors halve a battery's voltage onto pin 3, and a small icon in the corner shows its charge.
Parts list
10 parts, plus the jumper wires. They retail for about $27; on Mokxi they're free.
- 1 × ESP32-C3-DevKitM-1In Handheld Console Kit, Desk Buddy Kit.
- 1 × Full-size breadboard
- 1 × TFT display, 240x320In Handheld Console Kit.
- 1 × Game console buttonsIn Handheld Console Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 2 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Battery: AA, coin cell, LiPoIn Handheld Console Kit, Laser Sword Kit.
- 2 × Resistor, 100k ΩIn Starter Kit, Claw Machine Kit, Halloween Prop 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 |
|---|---|
| TFT display, 240x320 pin GND | ESP32-C3-DevKitM-1 pin GND |
| Piezo speaker pin 2 | ESP32-C3-DevKitM-1 pin GND |
| Pushbutton (1) pin 2a | ESP32-C3-DevKitM-1 pin GND |
| Pushbutton (2) pin 2a | ESP32-C3-DevKitM-1 pin GND |
| Battery: AA, coin cell, LiPo pin V- | ESP32-C3-DevKitM-1 pin GND |
| Resistor, 100k Ω (2) pin 1 | ESP32-C3-DevKitM-1 pin GND |
| TFT display, 240x320 pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| TFT display, 240x320 pin RST | ESP32-C3-DevKitM-1 pin 3V3 |
| TFT display, 240x320 pin LED | ESP32-C3-DevKitM-1 pin 3V3 |
| Pushbutton (1) pin 1a | ESP32-C3-DevKitM-1 pin 2 |
| Resistor, 100k Ω (1) pin 1 | ESP32-C3-DevKitM-1 pin 3 |
| Resistor, 100k Ω (2) pin 2 | ESP32-C3-DevKitM-1 pin 3 |
| Game console buttons pin GND | ESP32-C3-DevKitM-1 pin GND |
| Game console buttons pin B | ESP32-C3-DevKitM-1 pin 0 |
| Game console buttons pin A | ESP32-C3-DevKitM-1 pin 1 |
| Game console buttons pin RIGHT | ESP32-C3-DevKitM-1 pin 10 |
| Game console buttons pin LEFT | ESP32-C3-DevKitM-1 pin 4 |
| Game console buttons pin DOWN | ESP32-C3-DevKitM-1 pin 5 |
| Game console buttons pin UP | ESP32-C3-DevKitM-1 pin 6 |
| Piezo speaker pin 1 | ESP32-C3-DevKitM-1 pin 7 |
| Pushbutton (2) pin 1b | ESP32-C3-DevKitM-1 pin 9 |
| TFT display, 240x320 pin CS | ESP32-C3-DevKitM-1 pin 18 |
| TFT display, 240x320 pin DC | ESP32-C3-DevKitM-1 pin 19 |
| TFT display, 240x320 pin SCK | ESP32-C3-DevKitM-1 pin 20 |
| TFT display, 240x320 pin MOSI | ESP32-C3-DevKitM-1 pin 21 |
| Resistor, 100k Ω (1) pin 2 | Battery: AA, coin cell, LiPo pin V+ |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Handheld: a pocket game console on an ESP32-C3, with three games on it.
//
// Wiring:
// ILI9341 panel CS -> GPIO 18 DC -> GPIO 19 SCK -> GPIO 20 MOSI -> GPIO 21
// VCC, RST and LED -> 3V3 GND -> GND
// D-pad module UP -> 6 DOWN -> 5 LEFT -> 4 RIGHT -> 10
// A -> 1 B -> 0 GND -> GND
// Start pushbutton from GPIO 2 to GND
// Select pushbutton from GPIO 9 to GND (the pin the BOOT button is on)
// Piezo GPIO 7 to GND
// Battery gauge 18650 + -> 100k -> GPIO 3 -> 100k -> GND
//
// Every button is a contact to ground and reads through the chip's own
// pull-up, so none of them needs a resistor.
//
// On the canvas the arrow keys work the D-pad, Z is A and X is B. Start and
// Select are the two buttons on the breadboard.
//
// # The menu
//
// Up and down pick a game, A or Start plays it, B turns the sound on and off.
// In a game, Start pauses and Select goes back to the menu.
//
// BLOCKS left and right slide, A and B turn, down drops faster, up drops
// all the way. Clear lines to score; every ten lines is a level.
// RUNNER A jumps, and A again in the air jumps again. Hold it for a
// higher jump. Clear the crates and pick up the coins.
// SNAKE the D-pad steers. Eat the apples and do not bite yourself.
//
// # Drawing only what changed
//
// The panel is driven over bit-banged SPI, a pin change for every bit, so a
// full 240x320 repaint is about eighty milliseconds. A game that cleared the
// screen every frame would crawl. Instead each game keeps track of what is on
// the glass and paints the difference:
//
// - Blocks keeps a copy of the well as drawn and repaints only the cells
// that differ from the well as it is, falling piece and ghost included.
// - Runner moves a crate by painting the few columns it moved into and
// erasing the few it left, because every column of a crate looks the same.
// - Snake paints the new head, turns the old head into body and erases the
// tail: three cells a step, however long the snake is.
//
// # Sound without stopping
//
// tone() runs the piezo from the LEDC peripheral, so a note plays while the
// sketch carries on. A tiny sequencer walks a list of notes by the clock: the
// music loops underneath and a sound effect takes over the piezo for a moment
// when something happens, while the tune keeps its place.
//
// # The battery gauge
//
// Two 100k resistors halve the cell's 3.0 to 4.2 V into the ADC's range, and
// the icon in the corner shows four bars, then three, and so on, blinking red
// when the cell is nearly flat. In the simulator the board itself runs from
// USB, so change the cell's charge in its properties and watch the icon.
#include "mokxi_ili9341.h"
#include "mokxi_gamepad.h"
#include "pitches.h"
const uint8_t TFT_CS = 18;
const uint8_t TFT_DC = 19;
const uint8_t TFT_SCK = 20;
const uint8_t TFT_MOSI = 21;
const uint8_t PIEZO_PIN = 7;
const uint8_t BATTERY_PIN = 3;
SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);
/* up, down, left, right, A, B, Start, Select */
Cabinet pad(6, 5, 4, 10, 1, 0, 2, 9);
const int16_t SCREEN_W = 240;
const int16_t SCREEN_H = 320;
const uint32_t FRAME_MS = 25; // forty frames a second
constexpr uint16_t RGB(uint8_t r, uint8_t g, uint8_t b)
{
return (uint16_t)(((uint16_t)(r & 0xf8) << 8) | ((uint16_t)(g & 0xfc) << 3) | (b >> 3));
}
const uint16_t C_BG = RGB(14, 16, 30);
const uint16_t C_BAR = RGB(36, 40, 66);
const uint16_t C_CARD = RGB(28, 32, 54);
const uint16_t C_TEXT = RGB(240, 240, 250);
const uint16_t C_DIM = RGB(130, 140, 175);
const uint16_t C_ACCENT = RGB(255, 196, 40);
const uint16_t C_EDGE = RGB(70, 80, 120);
const uint16_t C_RED = RGB(235, 60, 60);
const uint16_t C_GREEN = RGB(70, 210, 90);
// The panel's drawing calls are inline in its header, so a sketch that calls
// them from a hundred places would carry a hundred copies. One copy of each,
// called from everywhere, keeps the program small.
static void __attribute__((noinline)) fill(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t c)
{
tft.fillRect(x, y, w, h, c);
}
static void __attribute__((noinline)) frame(int16_t x, int16_t y, int16_t w, int16_t h,
uint16_t c, int16_t t)
{
tft.rect(x, y, w, h, c, t);
}
static int16_t __attribute__((noinline)) text(int16_t x, int16_t y, const char *s, uint16_t fg,
uint16_t bg, uint8_t scale)
{
return tft.text(x, y, s, fg, bg, scale);
}
static int16_t __attribute__((noinline)) number(int16_t x, int16_t y, long v, uint16_t fg,
uint16_t bg, uint8_t scale, uint8_t digits = 0)
{
return tft.number(x, y, v, fg, bg, scale, digits);
}
static void __attribute__((noinline)) glyph(int16_t x, int16_t y, char ch, uint16_t fg,
uint16_t bg, uint8_t scale)
{
tft.drawChar(x, y, ch, fg, bg, scale);
}
// ------------------------------------------------------------------- sound
struct Note {
uint16_t hz;
uint16_t ms;
};
#define REST 0
// Original tunes, written for this console.
const Note MENU_SONG[] = {
{NOTE_E5, 200}, {NOTE_G5, 200}, {NOTE_C6, 200}, {NOTE_G5, 200},
{NOTE_A5, 200}, {NOTE_F5, 200}, {NOTE_D5, 200}, {NOTE_F5, 200},
{NOTE_G5, 200}, {NOTE_E5, 200}, {NOTE_C5, 200}, {NOTE_E5, 200},
{NOTE_D5, 200}, {NOTE_B4, 200}, {NOTE_G4, 400},
{NOTE_E5, 200}, {NOTE_G5, 200}, {NOTE_C6, 200}, {NOTE_E6, 200},
{NOTE_D6, 200}, {NOTE_B5, 200}, {NOTE_G5, 200}, {NOTE_A5, 200},
{NOTE_F5, 200}, {NOTE_D5, 200}, {NOTE_G5, 200}, {NOTE_B4, 200},
{NOTE_C5, 400}, {REST, 400},
};
const Note BLOCKS_SONG[] = {
{NOTE_A4, 170}, {NOTE_E5, 170}, {NOTE_C5, 170}, {NOTE_E5, 170},
{NOTE_A4, 170}, {NOTE_E5, 170}, {NOTE_C5, 170}, {NOTE_E5, 170},
{NOTE_G4, 170}, {NOTE_D5, 170}, {NOTE_B4, 170}, {NOTE_D5, 170},
{NOTE_G4, 170}, {NOTE_D5, 170}, {NOTE_B4, 170}, {NOTE_D5, 170},
{NOTE_F4, 170}, {NOTE_C5, 170}, {NOTE_A4, 170}, {NOTE_C5, 170},
{NOTE_F4, 170}, {NOTE_C5, 170}, {NOTE_A4, 170}, {NOTE_C5, 170},
{NOTE_E4, 170}, {NOTE_B4, 170}, {NOTE_GS4, 170}, {NOTE_B4, 170},
{NOTE_E4, 170}, {NOTE_B4, 170}, {NOTE_E5, 170}, {REST, 170},
};
const Note RUNNER_SONG[] = {
{NOTE_C5, 150}, {NOTE_C5, 150}, {NOTE_G4, 150}, {NOTE_C5, 150},
{NOTE_E5, 150}, {NOTE_D5, 150}, {NOTE_C5, 150}, {NOTE_G4, 150},
{NOTE_A4, 150}, {NOTE_A4, 150}, {NOTE_E4, 150}, {NOTE_A4, 150},
{NOTE_C5, 150}, {NOTE_B4, 150}, {NOTE_A4, 150}, {NOTE_E4, 150},
{NOTE_F4, 150}, {NOTE_A4, 150}, {NOTE_C5, 150}, {NOTE_F5, 150},
{NOTE_E5, 150}, {NOTE_C5, 150}, {NOTE_D5, 150}, {NOTE_B4, 150},
{NOTE_C5, 150}, {NOTE_G4, 150}, {NOTE_E4, 150}, {NOTE_G4, 150},
{NOTE_C5, 300}, {REST, 300},
};
const Note SFX_BOOT[] = {{NOTE_C6, 90}, {NOTE_C7, 420}};
const Note SFX_MOVE[] = {{NOTE_A6, 25}};
const Note SFX_START[] = {{NOTE_C5, 70}, {NOTE_E5, 70}, {NOTE_G5, 70}, {NOTE_C6, 150}};
const Note SFX_TOGGLE[] = {{NOTE_G5, 50}, {NOTE_C6, 70}};
const Note SFX_PAUSE[] = {{NOTE_G5, 60}, {NOTE_C5, 90}};
const Note SFX_TURN[] = {{NOTE_D6, 22}};
const Note SFX_LOCK[] = {{NOTE_G3, 35}};
const Note SFX_LINE[] = {{NOTE_C6, 55}, {NOTE_E6, 55}, {NOTE_G6, 55}, {NOTE_C7, 110}};
const Note SFX_FOUR[] = {{NOTE_C6, 60}, {NOTE_E6, 60}, {NOTE_G6, 60}, {NOTE_C7, 60},
{NOTE_G6, 60}, {NOTE_C7, 220}};
const Note SFX_JUMP[] = {{NOTE_C5, 30}, {NOTE_G5, 45}};
const Note SFX_HOP[] = {{NOTE_G5, 30}, {NOTE_D6, 45}};
const Note SFX_COIN[] = {{NOTE_B5, 45}, {NOTE_E6, 110}};
const Note SFX_EAT[] = {{NOTE_E5, 30}, {NOTE_B5, 60}};
const Note SFX_OVER[] = {{NOTE_B4, 150}, {NOTE_G4, 150}, {NOTE_E4, 150}, {NOTE_C4, 420}};
#define LEN(a) ((uint8_t)(sizeof(a) / sizeof((a)[0])))
struct Track {
const Note *notes;
uint8_t len;
uint8_t pos;
bool loop;
uint32_t start; // when the current note began
};
Track music = {nullptr, 0, 0, true, 0};
Track effect = {nullptr, 0, 0, false, 0};
bool soundOn = true;
uint16_t sounding = 0;
static void playMusic(const Note *notes, uint8_t len)
{
music = {notes, len, 0, true, millis()};
}
static void stopMusic()
{
music.notes = nullptr;
}
static void sfx(const Note *notes, uint8_t len)
{
effect = {notes, len, 0, false, millis()};
}
/** The pitch a track wants now, after walking it up to the clock. */
static uint16_t trackPitch(Track &t, uint32_t now)
{
if (t.notes == nullptr) {
return 0;
}
while (now - t.start >= t.notes[t.pos].ms) {
t.start += t.notes[t.pos].ms;
t.pos++;
if (t.pos >= t.len) {
if (!t.loop) {
t.notes = nullptr;
return 0;
}
t.pos = 0;
}
}
const Note &n = t.notes[t.pos];
// A short gap at the end of every note, so two of the same pitch are two.
uint16_t gap = n.ms / 5 < 18 ? n.ms / 5 : 18;
return (now - t.start < (uint32_t)(n.ms - gap)) ? n.hz : 0;
}
static void soundUpdate()
{
uint32_t now = millis();
uint16_t tune = trackPitch(music, now);
uint16_t want = effect.notes != nullptr ? trackPitch(effect, now) : tune;
if (effect.notes == nullptr && want == 0) {
want = tune;
}
if (!soundOn) {
want = 0;
}
if (want != sounding) {
if (want != 0) {
tone(PIEZO_PIN, want);
} else {
noTone(PIEZO_PIN);
}
sounding = want;
}
}
// ------------------------------------------------------------------ buttons
uint32_t heldSince[CABINET_BUTTONS];
uint32_t lastRepeat[CABINET_BUTTONS];
/** A press, and then a steady repeat while it is held, like a keyboard. */
static bool repeatPress(uint8_t b, uint16_t first = 170, uint16_t every = 55)
{
uint32_t now = millis();
if (pad.pressed(b)) {
heldSince[b] = now;
lastRepeat[b] = now;
return true;
}
if (pad.held(b) && now - heldSince[b] >= first && now - lastRepeat[b] >= every) {
lastRepeat[b] = now;
return true;
}
return false;
}
// ------------------------------------------------------------- drawing kit
static int16_t centerX(const char *s, uint8_t scale)
{
int16_t n = 0;
while (s[n] != '\0') {
n++;
}
return (int16_t)((SCREEN_W - n * FONT_CELL * scale) / 2);
}
static void textCenter(int16_t y, const char *s, uint16_t fg, uint16_t bg, uint8_t scale)
{
text(centerX(s, scale), y, s, fg, bg, scale);
}
/**
* Pixel art from strings: one character per pixel, each a color from the
* palette by its position in `keys`, and '.' for the background. Runs of one
* color go out as one rectangle, so a sprite costs a few dozen fills.
*/
static void sprite(int16_t x, int16_t y, const char *const *rows, uint8_t h, uint8_t scale,
const char *keys, const uint16_t *colors, uint16_t bg)
{
for (uint8_t r = 0; r < h; r++) {
const char *row = rows[r];
uint8_t c = 0;
while (row[c] != '\0') {
char k = row[c];
uint8_t run = 1;
while (row[c + run] == k) {
run++;
}
uint16_t color = bg;
for (uint8_t i = 0; keys[i] != '\0'; i++) {
if (keys[i] == k) {
color = colors[i];
}
}
fill((int16_t)(x + c * scale), (int16_t)(y + r * scale), (int16_t)(run * scale),
scale, color);
c = (uint8_t)(c + run);
}
}
}
// ----------------------------------------------------------------- battery
int8_t batteryPercent = -1; // -1: no cell on the gauge
int8_t shownBars = -9;
bool shownBlink = false;
uint32_t batteryAt = 0;
uint16_t batteryMv = 0;
const int16_t BAT_X = 206;
const int16_t BAT_Y = 5;
static void drawBattery(bool force)
{
int8_t bars = batteryPercent < 0 ? -1 : (int8_t)((batteryPercent + 24) / 25);
bool low = batteryPercent >= 0 && batteryPercent < 15;
bool blink = low && ((millis() / 500) % 2 == 0);
if (!force && bars == shownBars && blink == shownBlink) {
return;
}
shownBars = bars;
shownBlink = blink;
fill(BAT_X - 4, 2, 32, 16, C_BAR);
if (bars < 0) {
// No cell on the gauge: the board is on USB.
text(BAT_X, BAT_Y + 2, "USB", C_DIM, C_BAR, 1);
return;
}
uint16_t edge = low ? (blink ? C_RED : C_BAR) : C_TEXT;
frame(BAT_X, BAT_Y, 22, 11, edge, 1);
fill(BAT_X + 22, BAT_Y + 3, 2, 5, edge);
uint16_t level = batteryPercent > 50 ? C_GREEN : (batteryPercent > 20 ? C_ACCENT : C_RED);
for (int8_t i = 0; i < 4; i++) {
fill((int16_t)(BAT_X + 2 + i * 5), BAT_Y + 2, 4, 7, i < bars ? level : C_BAR);
}
}
static void batteryUpdate()
{
uint32_t now = millis();
if (now - batteryAt >= 1000 || batteryAt == 0) {
batteryAt = now;
int raw = analogRead(BATTERY_PIN);
int8_t percent;
if (raw < 60) {
percent = -1;
batteryMv = 0;
} else {
// The divider halves the cell. The raw reading is not linear (a C3
// saturates at about 2.9 V), so ask for calibrated millivolts.
batteryMv = (uint16_t)(analogReadMilliVolts(BATTERY_PIN) * 2u);
long p = ((long)batteryMv - 3300) * 100 / 900;
percent = (int8_t)(p < 0 ? 0 : (p > 100 ? 100 : p));
}
if (percent / 5 != batteryPercent / 5 || (percent < 0) != (batteryPercent < 0)) {
if (percent < 0) {
Serial.println("Battery: none, running on USB");
} else {
Serial.print("Battery: ");
Serial.print(batteryMv / 1000.0, 2);
Serial.print(" V, ");
Serial.print(percent);
Serial.println("%");
}
}
batteryPercent = percent;
}
drawBattery(false);
}
// -------------------------------------------------------------- status bar
static void drawBar(const char *title)
{
fill(0, 0, SCREEN_W, 20, C_BAR);
text(8, 7, title, C_TEXT, C_BAR, 1);
drawBattery(true);
}
static void barScore(long value)
{
number(130, 7, value, C_ACCENT, C_BAR, 1, 7);
}
// ------------------------------------------------------------------ screens
enum Screen : uint8_t { MENU, BLOCKS, RUNNER, SNAKE };
Screen screen = MENU;
bool paused = false;
bool over = false;
uint32_t overAt = 0;
uint8_t choice = 0;
uint32_t best[3] = {0, 0, 0};
const char *const NAMES[3] = {"BLOCKS", "RUNNER", "SNAKE"};
bool seeded = false;
static void startGame(uint8_t which);
static void drawMenu();
/** The panel a game puts up when it ends, over whatever is there. */
static void showGameOver(uint8_t game, uint32_t score)
{
bool record = score > best[game];
if (record) {
best[game] = score;
}
over = true;
overAt = millis();
stopMusic();
sfx(SFX_OVER, LEN(SFX_OVER));
Serial.print("Game over: ");
Serial.print(NAMES[game]);
Serial.print(" ");
Serial.println(score);
const int16_t x = 30, y = 112, w = 180, h = 104;
fill(x, y, w, h, C_CARD);
frame(x, y, w, h, C_ACCENT, 2);
textCenter(y + 12, "GAME OVER", C_TEXT, C_CARD, 2);
text(x + 24, y + 40, record ? "NEW BEST" : "SCORE", record ? C_ACCENT : C_DIM, C_CARD, 1);
number(x + 92, y + 40, (long)score, C_TEXT, C_CARD, 1, 9);
text(x + 24, y + 56, "BEST", C_DIM, C_CARD, 1);
number(x + 92, y + 56, (long)best[game], C_TEXT, C_CARD, 1, 9);
textCenter(y + 80, "A AGAIN SELECT MENU", C_DIM, C_CARD, 1);
}
static void goMenu()
{
screen = MENU;
paused = false;
over = false;
Serial.println("Menu");
drawMenu();
playMusic(MENU_SONG, LEN(MENU_SONG));
}
// ---------------------------------------------------------------- the menu
const int16_t CARD_X = 16;
const int16_t CARD_W = 208;
const int16_t CARD_H = 56;
const int16_t CARD_Y0 = 84;
const int16_t CARD_STEP = 64;
static void drawIcon(uint8_t i, int16_t x, int16_t y)
{
fill(x, y, 40, 40, C_BG);
if (i == 0) {
// a T and an L, stacked
const uint16_t t = RGB(170, 80, 230);
const uint16_t l = RGB(255, 150, 40);
fill(x + 4, y + 8, 30, 9, t);
fill(x + 14, y + 17, 10, 9, t);
fill(x + 4, y + 26, 9, 10, l);
fill(x + 4, y + 17, 9, 9, l);
fill(x + 24, y + 26, 10, 10, RGB(60, 200, 230));
fill(x + 13, y + 26, 11, 10, RGB(60, 200, 230));
} else if (i == 1) {
// a runner and a crate on the ground
fill(x, y + 34, 40, 6, RGB(90, 170, 70));
fill(x + 6, y + 10, 10, 10, RGB(250, 205, 160));
fill(x + 5, y + 20, 12, 10, RGB(60, 120, 230));
fill(x + 6, y + 30, 4, 4, RGB(40, 40, 60));
fill(x + 13, y + 30, 4, 4, RGB(40, 40, 60));
fill(x + 24, y + 20, 14, 14, RGB(190, 120, 60));
fill(x + 24, y + 26, 14, 2, RGB(120, 70, 30));
} else {
// a snake and an apple
const uint16_t g = C_GREEN;
fill(x + 2, y + 26, 24, 8, g);
fill(x + 18, y + 10, 8, 16, g);
fill(x + 18, y + 10, 14, 8, g);
fill(x + 28, y + 12, 2, 2, C_BG);
fill(x + 30, y + 26, 8, 8, C_RED);
fill(x + 33, y + 23, 2, 3, RGB(110, 70, 30));
}
}
static void drawCardFrame(uint8_t i, bool on)
{
int16_t y = (int16_t)(CARD_Y0 + i * CARD_STEP);
frame(CARD_X - 3, y - 3, CARD_W + 6, CARD_H + 6, on ? C_ACCENT : C_BG, 3);
}
static void drawSoundLine()
{
textCenter(286, soundOn ? "A PLAY B SOUND: ON " : "A PLAY B SOUND: OFF", C_DIM, C_BG, 1);
}
static void drawMenu()
{
tft.fillScreen(C_BG);
drawBar("MOKXI POCKET");
// The logo: five letters, five colors.
const char *logo = "MOKXI";
const uint16_t colors[5] = {RGB(255, 90, 90), RGB(255, 196, 40), RGB(80, 210, 110),
RGB(70, 170, 255), RGB(190, 110, 255)};
for (uint8_t i = 0; i < 5; i++) {
glyph((int16_t)(60 + i * 24), 32, logo[i], colors[i], colors[i], 4);
}
textCenter(66, "POCKET CONSOLE", C_DIM, C_BG, 1);
for (uint8_t i = 0; i < 3; i++) {
int16_t y = (int16_t)(CARD_Y0 + i * CARD_STEP);
fill(CARD_X, y, CARD_W, CARD_H, C_CARD);
drawIcon(i, CARD_X + 8, y + 8);
text(CARD_X + 60, y + 12, NAMES[i], C_TEXT, C_CARD, 2);
text(CARD_X + 60, y + 34, "BEST", C_DIM, C_CARD, 1);
number(CARD_X + 90, y + 34, (long)best[i], C_DIM, C_CARD, 1);
drawCardFrame(i, i == choice);
}
drawSoundLine();
textCenter(302, "START PAUSE SELECT MENU", C_EDGE, C_BG, 1);
}
static void menuUpdate()
{
int8_t move = 0;
if (repeatPress(PAD_UP, 250, 160)) {
move = -1;
} else if (repeatPress(PAD_DOWN, 250, 160)) {
move = 1;
}
if (move != 0) {
drawCardFrame(choice, false);
choice = (uint8_t)((choice + 3 + move) % 3);
drawCardFrame(choice, true);
sfx(SFX_MOVE, LEN(SFX_MOVE));
}
if (pad.pressed(PAD_B)) {
soundOn = !soundOn;
drawSoundLine();
sfx(SFX_TOGGLE, LEN(SFX_TOGGLE));
Serial.println(soundOn ? "Sound on" : "Sound off");
}
if (pad.pressed(PAD_A) || pad.pressed(PAD_START)) {
sfx(SFX_START, LEN(SFX_START));
startGame(choice);
}
}
// ================================================================== BLOCKS
const int8_t WELL_W = 10;
const int8_t WELL_H = 20;
const int16_t CELL = 12;
const int16_t WELL_X = 12;
const int16_t WELL_Y = 34;
// Seven pieces, four turns each, as 4x4 masks read row by row from the top.
const uint16_t SHAPES[7][4] = {
{0x0F00, 0x2222, 0x00F0, 0x4444}, // I
{0x8E00, 0x6440, 0x0E20, 0x44C0}, // J
{0x2E00, 0x4460, 0x0E80, 0xC440}, // L
{0x6600, 0x6600, 0x6600, 0x6600}, // O
{0x6C00, 0x4620, 0x06C0, 0x8C40}, // S
{0x4E00, 0x4640, 0x0E40, 0x4C40}, // T
{0xC600, 0x2640, 0x0C60, 0x4C80}, // Z
};
const uint16_t PIECE[7] = {RGB(60, 210, 235), RGB(60, 110, 240), RGB(255, 150, 40),
RGB(250, 215, 50), RGB(80, 215, 90), RGB(175, 85, 235),
RGB(240, 70, 70)};
const uint16_t PIECE_HI[7] = {RGB(170, 245, 255), RGB(150, 180, 255), RGB(255, 205, 140),
RGB(255, 245, 170), RGB(170, 245, 170), RGB(220, 170, 255),
RGB(255, 160, 160)};
const uint16_t PIECE_LO[7] = {RGB(20, 120, 140), RGB(25, 50, 150), RGB(170, 85, 10),
RGB(160, 130, 10), RGB(30, 130, 40), RGB(100, 40, 150),
RGB(150, 30, 30)};
/* What a cell holds: 0 empty, 1..7 a piece, 8..14 the ghost of one, 15 a flash. */
uint8_t well[WELL_H][WELL_W];
uint8_t drawn[WELL_H][WELL_W];
int8_t pType, pRot, pX, pY;
int8_t nextType;
uint8_t bag[7];
uint8_t bagLeft = 0;
uint32_t bScore;
uint16_t bLines;
uint8_t bLevel;
uint32_t fallAt;
bool resting;
uint32_t restSince;
uint32_t clearRows; // one bit a row being cleared
uint32_t clearUntil;
uint32_t shownScore;
uint16_t shownLines;
static bool shapeAt(int8_t type, int8_t rot, int8_t r, int8_t c)
{
return (SHAPES[type][rot] & (0x8000u >> (r * 4 + c))) != 0u;
}
static bool fits(int8_t type, int8_t rot, int8_t x, int8_t y)
{
for (int8_t r = 0; r < 4; r++) {
for (int8_t c = 0; c < 4; c++) {
if (!shapeAt(type, rot, r, c)) {
continue;
}
int8_t wx = (int8_t)(x + c);
int8_t wy = (int8_t)(y + r);
if (wx < 0 || wx >= WELL_W || wy >= WELL_H) {
return false;
}
if (wy >= 0 && well[wy][wx] != 0) {
return false;
}
}
}
return true;
}
/** The shuffled bag: every piece once in each seven, the way modern games deal. */
static int8_t dealPiece()
{
if (bagLeft == 0) {
for (uint8_t i = 0; i < 7; i++) {
bag[i] = i;
}
for (uint8_t i = 6; i > 0; i--) {
uint8_t j = (uint8_t)random(i + 1);
uint8_t t = bag[i];
bag[i] = bag[j];
bag[j] = t;
}
bagLeft = 7;
}
return (int8_t)bag[--bagLeft];
}
static void drawBlock(int16_t x, int16_t y, int16_t size, uint8_t type)
{
fill(x, y, size, size, PIECE[type]);
fill(x, y, size, 2, PIECE_HI[type]);
fill(x, y + 2, 2, size - 2, PIECE_HI[type]);
fill(x + 2, y + size - 2, size - 2, 2, PIECE_LO[type]);
fill(x + size - 2, y + 2, 2, size - 4, PIECE_LO[type]);
}
static void drawWellCell(int8_t r, int8_t c, uint8_t v)
{
int16_t x = (int16_t)(WELL_X + c * CELL);
int16_t y = (int16_t)(WELL_Y + r * CELL);
if (v == 0) {
fill(x, y, CELL, CELL, C_BG);
fill(x + 5, y + 5, 2, 2, C_CARD);
} else if (v <= 7) {
drawBlock(x, y, CELL - 1, (uint8_t)(v - 1));
fill(x + CELL - 1, y, 1, CELL, C_BG);
fill(x, y + CELL - 1, CELL - 1, 1, C_BG);
} else if (v <= 14) {
fill(x, y, CELL, CELL, C_BG);
frame(x + 1, y + 1, CELL - 3, CELL - 3, PIECE_LO[v - 8], 1);
} else {
fill(x, y, CELL - 1, CELL - 1, C_TEXT);
}
}
/** Paint every cell whose picture differs from what is on the glass. */
static void renderWell()
{
uint8_t want[WELL_H][WELL_W];
for (int8_t r = 0; r < WELL_H; r++) {
for (int8_t c = 0; c < WELL_W; c++) {
want[r][c] = ((clearRows >> r) & 1u) ? 15 : well[r][c];
}
}
if (clearRows == 0 && !over) {
int8_t ghost = pY;
while (fits(pType, pRot, pX, (int8_t)(ghost + 1))) {
ghost++;
}
for (int8_t r = 0; r < 4; r++) {
for (int8_t c = 0; c < 4; c++) {
if (!shapeAt(pType, pRot, r, c)) {
continue;
}
int8_t gy = (int8_t)(ghost + r);
int8_t wx = (int8_t)(pX + c);
if (gy >= 0 && want[gy][wx] == 0) {
want[gy][wx] = (uint8_t)(8 + pType);
}
}
}
for (int8_t r = 0; r < 4; r++) {
for (int8_t c = 0; c < 4; c++) {
if (shapeAt(pType, pRot, r, c) && pY + r >= 0) {
want[pY + r][pX + c] = (uint8_t)(1 + pType);
}
}
}
}
for (int8_t r = 0; r < WELL_H; r++) {
for (int8_t c = 0; c < WELL_W; c++) {
if (want[r][c] != drawn[r][c]) {
drawWellCell(r, c, want[r][c]);
drawn[r][c] = want[r][c];
}
}
}
}
static void drawNext()
{
fill(150, 50, 76, 52, C_CARD);
for (int8_t r = 0; r < 4; r++) {
for (int8_t c = 0; c < 4; c++) {
if (shapeAt(nextType, 0, r, c)) {
// centred in the box: the I and the O are two columns wide at heart
int16_t ox = (nextType == 0 || nextType == 3) ? 168 : 173;
drawBlock((int16_t)(ox + c * 10), (int16_t)(58 + r * 10), 9, (uint8_t)nextType);
}
}
}
}
static void drawBlocksStats()
{
if (bScore != shownScore) {
number(150, 136, (long)bScore, C_TEXT, C_BG, 2, 6);
barScore((long)bScore);
shownScore = bScore;
}
if (bLines != shownLines) {
number(150, 184, bLines, C_TEXT, C_BG, 2, 4);
number(150, 232, bLevel, C_TEXT, C_BG, 2, 3);
shownLines = bLines;
}
}
static bool spawnPiece()
{
pType = nextType;
nextType = dealPiece();
pRot = 0;
pX = 3;
pY = (int8_t)(pType == 0 ? -1 : 0);
resting = false;
fallAt = millis();
drawNext();
return fits(pType, pRot, pX, pY);
}
static void blocksStart()
{
tft.fillScreen(C_BG);
drawBar("BLOCKS");
frame(WELL_X - 3, WELL_Y - 3, WELL_W * CELL + 5, WELL_H * CELL + 5, C_EDGE, 2);
text(150, 38, "NEXT", C_DIM, C_BG, 1);
text(150, 122, "SCORE", C_DIM, C_BG, 1);
text(150, 170, "LINES", C_DIM, C_BG, 1);
text(150, 218, "LEVEL", C_DIM, C_BG, 1);
text(146, 282, "A B TURN", C_EDGE, C_BG, 1);
text(146, 296, "UP DROP", C_EDGE, C_BG, 1);
for (int8_t r = 0; r < WELL_H; r++) {
for (int8_t c = 0; c < WELL_W; c++) {
well[r][c] = 0;
drawn[r][c] = 0xFF;
}
}
bScore = 0;
bLines = 0;
bLevel = 1;
shownScore = 0xFFFFFFFFu;
shownLines = 0xFFFF;
clearRows = 0;
bagLeft = 0;
nextType = dealPiece();
spawnPiece();
renderWell();
drawBlocksStats();
playMusic(BLOCKS_SONG, LEN(BLOCKS_SONG));
}
static uint32_t fallInterval()
{
int32_t ms = 760 - (int32_t)(bLevel - 1) * 65;
return (uint32_t)(ms < 70 ? 70 : ms);
}
static void lockPiece()
{
for (int8_t r = 0; r < 4; r++) {
for (int8_t c = 0; c < 4; c++) {
if (shapeAt(pType, pRot, r, c) && pY + r >= 0) {
well[pY + r][pX + c] = (uint8_t)(1 + pType);
}
}
}
clearRows = 0;
uint8_t count = 0;
for (int8_t r = 0; r < WELL_H; r++) {
bool full = true;
for (int8_t c = 0; c < WELL_W; c++) {
if (well[r][c] == 0) {
full = false;
}
}
if (full) {
clearRows |= (1u << r);
count++;
}
}
if (count == 0) {
sfx(SFX_LOCK, LEN(SFX_LOCK));
if (!spawnPiece()) {
renderWell();
showGameOver(0, bScore);
}
return;
}
const uint16_t points[5] = {0, 100, 300, 500, 800};
bScore += (uint32_t)points[count] * bLevel;
bLines = (uint16_t)(bLines + count);
uint8_t level = (uint8_t)(1 + bLines / 10);
if (count == 4) {
sfx(SFX_FOUR, LEN(SFX_FOUR));
} else {
sfx(SFX_LINE, LEN(SFX_LINE));
}
bLevel = level;
clearUntil = millis() + 220;
Serial.print("Blocks: ");
Serial.print(count);
Serial.print(count == 1 ? " line, score " : " lines, score ");
Serial.println(bScore);
}
static void finishClear()
{
int8_t to = WELL_H - 1;
for (int8_t from = WELL_H - 1; from >= 0; from--) {
if ((clearRows >> from) & 1u) {
continue;
}
for (int8_t c = 0; c < WELL_W; c++) {
well[to][c] = well[from][c];
}
to--;
}
while (to >= 0) {
for (int8_t c = 0; c < WELL_W; c++) {
well[to][c] = 0;
}
to--;
}
clearRows = 0;
if (!spawnPiece()) {
renderWell();
showGameOver(0, bScore);
}
}
static bool tryMove(int8_t dx, int8_t dy)
{
if (fits(pType, pRot, (int8_t)(pX + dx), (int8_t)(pY + dy))) {
pX = (int8_t)(pX + dx);
pY = (int8_t)(pY + dy);
if (dx != 0) {
restSince = millis();
}
return true;
}
return false;
}
static void tryTurn(int8_t dir)
{
int8_t rot = (int8_t)((pRot + 4 + dir) % 4);
const int8_t kicks[5] = {0, -1, 1, -2, 2};
for (uint8_t i = 0; i < 5; i++) {
if (fits(pType, rot, (int8_t)(pX + kicks[i]), pY)) {
pX = (int8_t)(pX + kicks[i]);
pRot = rot;
restSince = millis();
sfx(SFX_TURN, LEN(SFX_TURN));
return;
}
}
if (fits(pType, rot, pX, (int8_t)(pY - 1))) {
pY = (int8_t)(pY - 1);
pRot = rot;
sfx(SFX_TURN, LEN(SFX_TURN));
}
}
static void blocksUpdate()
{
uint32_t now = millis();
if (clearRows != 0) {
if ((int32_t)(now - clearUntil) >= 0) {
finishClear();
}
renderWell();
drawBlocksStats();
return;
}
if (repeatPress(PAD_LEFT)) {
tryMove(-1, 0);
}
if (repeatPress(PAD_RIGHT)) {
tryMove(1, 0);
}
if (pad.pressed(PAD_A)) {
tryTurn(1);
}
if (pad.pressed(PAD_B)) {
tryTurn(-1);
}
if (pad.pressed(PAD_UP)) {
uint8_t rows = 0;
while (tryMove(0, 1)) {
rows++;
}
bScore += (uint32_t)rows * 2u;
lockPiece();
} else {
bool soft = pad.held(PAD_DOWN);
uint32_t interval = soft ? 35u : fallInterval();
if (now - fallAt >= interval) {
fallAt = now;
if (tryMove(0, 1)) {
resting = false;
if (soft) {
bScore += 1;
}
} else if (!resting) {
resting = true;
restSince = now;
}
}
if (resting && !fits(pType, pRot, pX, (int8_t)(pY + 1))) {
if (now - restSince >= 400) {
lockPiece();
}
} else {
resting = false;
}
}
if (!over) {
renderWell();
drawBlocksStats();
}
}
// ================================================================== RUNNER
const int16_t GROUND_Y = 232; // the top of the grass
const int16_t SKY_TOP = 20;
const uint16_t C_SKY = RGB(110, 180, 245);
const uint16_t C_GRASS = RGB(80, 180, 70);
const uint16_t C_DIRT = RGB(130, 85, 45);
const uint16_t C_CRATE = RGB(200, 130, 60);
const uint16_t C_CRATE_DARK = RGB(120, 70, 30);
const uint16_t C_CLOUD = RGB(245, 250, 255);
const uint16_t C_COIN = RGB(255, 210, 40);
const int16_t RUNNER_X = 34;
const int16_t RUNNER_W = 20;
const int16_t RUNNER_H = 24;
// Ten by twelve, drawn twice the size. K outline, S skin, W eye, B shirt, P trousers.
const char *const RUN_A[12] = {
"...KKKK...", "..KSSSSK..", "..KSSWKK..", "..KSSSSK..", "...KKKK...", "..KBBBBK..",
".KSBBBBSK.", "..KBBBBK..", "..KPPPPK..", "..KP..PK..", ".KK...KK..", "..........",
};
const char *const RUN_B[12] = {
"...KKKK...", "..KSSSSK..", "..KSSWKK..", "..KSSSSK..", "...KKKK...", "..KBBBBK..",
".KSBBBBSK.", "..KBBBBK..", "..KPPPPK..", "...KPPK...", "....KK....", "..........",
};
const char *const RUN_JUMP[12] = {
"...KKKK...", "..KSSSSK..", "..KSSWKK..", "..KSSSSK..", "KK.KKKK.KK", "SKKBBBBKKS",
"..KBBBBK..", "..KBBBBK..", "..KPPPPK..", ".KP....PK.", ".K......K.", "..........",
};
const char RUN_KEYS[] = "KSWBP";
const uint16_t RUN_COLORS[] = {RGB(30, 30, 45), RGB(250, 205, 160), RGB(255, 255, 255),
RGB(230, 60, 70), RGB(50, 80, 170)};
struct Crate {
bool live;
int16_t x; // whole pixels
int16_t w;
int16_t h;
};
struct Coin {
bool live;
int16_t x;
int16_t y;
};
struct Cloud {
int16_t x;
int16_t y;
};
const uint8_t CRATES = 3;
const uint8_t COINS = 4;
Crate crates[CRATES];
Coin coins[COINS];
Cloud clouds[2];
int16_t pebbles[4];
int32_t ry; // the runner's top edge, in sixteenths of a pixel
int32_t rvy; // and its speed, sixteenths a frame
int16_t shownY; // where it is on the glass, whole pixels
uint8_t shownPose;
bool jumpedTwice;
int32_t speed; // sixteenths of a pixel a frame
int32_t subpixel; // the fraction of a pixel the world has yet to move
int16_t gapLeft; // pixels until the next crate may appear
uint32_t distance; // pixels run
uint16_t coinCount;
uint32_t rScore;
uint32_t rShown;
uint16_t frameNo;
static void drawRunner(uint8_t pose, int16_t y)
{
const char *const *art = pose == 0 ? RUN_A : (pose == 1 ? RUN_B : RUN_JUMP);
sprite(RUNNER_X, y, art, 12, 2, RUN_KEYS, RUN_COLORS, C_SKY);
}
/** Every column of a crate looks the same, so a crate can be drawn by columns. */
static void crateColumns(int16_t x, int16_t w, int16_t h)
{
if (w <= 0) {
return;
}
int16_t top = (int16_t)(GROUND_Y - h);
fill(x, top, w, h, C_CRATE);
fill(x, top, w, 2, C_CRATE_DARK);
fill(x, (int16_t)(top + h / 2 - 1), w, 2, C_CRATE_DARK);
}
static void crateEdge(int16_t x, int16_t h)
{
fill(x, (int16_t)(GROUND_Y - h), 2, h, C_CRATE_DARK);
}
static void moveCrate(Crate &c, int16_t d)
{
int16_t nx = (int16_t)(c.x - d);
int16_t top = (int16_t)(GROUND_Y - c.h);
// the columns it moved into, the old left edge with them
crateColumns((int16_t)(nx + 2), d, c.h);
crateEdge(nx, c.h);
// the right edge moves too, and the columns behind it go back to sky
crateEdge((int16_t)(nx + c.w - 2), c.h);
fill((int16_t)(nx + c.w), top, d, c.h, C_SKY);
c.x = nx;
if (c.x + c.w < 0) {
c.live = false;
}
}
static void drawCoin(int16_t x, int16_t y, bool on)
{
if (!on) {
fill(x, y, 10, 10, C_SKY);
return;
}
fill(x + 2, y, 6, 10, C_COIN);
fill(x, y + 2, 10, 6, C_COIN);
fill(x + 4, y + 2, 2, 6, RGB(200, 140, 20));
}
static void drawCloud(int16_t x, int16_t y)
{
fill(x, y + 8, 40, 10, C_CLOUD);
fill(x + 10, y, 20, 8, C_CLOUD);
}
static void moveCloud(Cloud &c)
{
// one pixel left: a column of white in front, a column of sky behind
fill(c.x - 1, c.y + 8, 1, 10, C_CLOUD);
fill(c.x + 39, c.y + 8, 1, 10, C_SKY);
fill(c.x + 9, c.y, 1, 8, C_CLOUD);
fill(c.x + 29, c.y, 1, 8, C_SKY);
c.x--;
if (c.x < -42) {
c.x = (int16_t)(SCREEN_W + random(10, 80));
}
}
static void runnerScene()
{
fill(0, SKY_TOP, SCREEN_W, GROUND_Y - SKY_TOP, C_SKY);
fill(0, GROUND_Y, SCREEN_W, 6, C_GRASS);
fill(0, GROUND_Y + 6, SCREEN_W, 26, C_DIRT);
fill(0, GROUND_Y + 32, SCREEN_W, SCREEN_H - GROUND_Y - 32, C_BG);
text(12, 280, "COINS", C_DIM, C_BG, 1);
text(12, 298, "A JUMP, A AGAIN TO HOP", C_EDGE, C_BG, 1);
for (uint8_t i = 0; i < 2; i++) {
drawCloud(clouds[i].x, clouds[i].y);
}
}
static void runnerStart()
{
fill(0, 0, SCREEN_W, SCREEN_H, C_BG);
drawBar("RUNNER");
for (uint8_t i = 0; i < CRATES; i++) {
crates[i].live = false;
}
for (uint8_t i = 0; i < COINS; i++) {
coins[i].live = false;
}
clouds[0] = {150, 50};
clouds[1] = {40, 100};
for (uint8_t i = 0; i < 4; i++) {
pebbles[i] = (int16_t)(i * 64 + 20);
}
runnerScene();
ry = (int32_t)(GROUND_Y - RUNNER_H) * 16;
rvy = 0;
shownY = (int16_t)(ry / 16);
shownPose = 0;
drawRunner(0, shownY);
jumpedTwice = false;
speed = 48;
subpixel = 0;
gapLeft = 160;
distance = 0;
coinCount = 0;
rScore = 0;
rShown = 0xFFFFFFFFu;
frameNo = 0;
number(50, 280, 0, C_ACCENT, C_BG, 1, 4);
playMusic(RUNNER_SONG, LEN(RUNNER_SONG));
}
static void spawnCrate()
{
for (uint8_t i = 0; i < CRATES; i++) {
if (!crates[i].live) {
static const int16_t heights[3] = {18, 26, 34};
crates[i].live = true;
crates[i].x = SCREEN_W;
crates[i].w = random(4) == 0 ? 30 : 18;
crates[i].h = heights[random(3)];
// a coin or two above it, sometimes
if (random(3) == 0) {
for (uint8_t j = 0; j < COINS; j++) {
if (!coins[j].live) {
coins[j] = {true, (int16_t)(SCREEN_W + crates[i].w / 2 - 5),
(int16_t)(GROUND_Y - crates[i].h - 34)};
break;
}
}
}
int16_t base = (int16_t)(130 - speed / 2);
gapLeft = (int16_t)(base + random(0, 110));
return;
}
}
}
static bool hits(int16_t ax, int16_t ay, int16_t aw, int16_t ah, int16_t bx, int16_t by,
int16_t bw, int16_t bh)
{
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
static void runnerUpdate()
{
frameNo++;
// ---- the runner
bool onGround = ry >= (int32_t)(GROUND_Y - RUNNER_H) * 16;
if (pad.pressed(PAD_A) || pad.pressed(PAD_UP)) {
if (onGround) {
rvy = -150;
jumpedTwice = false;
sfx(SFX_JUMP, LEN(SFX_JUMP));
} else if (!jumpedTwice) {
rvy = -120;
jumpedTwice = true;
sfx(SFX_HOP, LEN(SFX_HOP));
}
}
// Let go early and the jump is shorter.
if (!pad.held(PAD_A) && !pad.held(PAD_UP) && rvy < -50) {
rvy = -50;
}
rvy += 10;
ry += rvy;
int32_t floor16 = (int32_t)(GROUND_Y - RUNNER_H) * 16;
if (ry >= floor16) {
ry = floor16;
rvy = 0;
}
onGround = ry >= floor16;
// ---- the world moves left by a whole number of pixels this frame
subpixel += speed;
int16_t d = (int16_t)(subpixel / 16);
subpixel %= 16;
distance += (uint32_t)d;
for (uint8_t i = 0; i < CRATES; i++) {
if (crates[i].live && d > 0) {
moveCrate(crates[i], d);
}
}
gapLeft = (int16_t)(gapLeft - d);
if (gapLeft <= 0) {
spawnCrate();
}
if (frameNo % 3 == 0) {
for (uint8_t i = 0; i < 2; i++) {
moveCloud(clouds[i]);
}
}
for (uint8_t i = 0; i < 4; i++) {
fill(pebbles[i], GROUND_Y + 14, 4, 2, C_DIRT);
pebbles[i] = (int16_t)(pebbles[i] - d);
if (pebbles[i] < -4) {
pebbles[i] = (int16_t)(pebbles[i] + 256);
}
fill(pebbles[i], GROUND_Y + 14, 4, 2, RGB(90, 55, 25));
}
// ---- the runner, drawn where it is now
int16_t y = (int16_t)(ry / 16);
uint8_t pose = onGround ? (uint8_t)((frameNo / 5) % 2) : 2;
if (y != shownY || pose != shownPose) {
// erase only the strip the sprite no longer covers
if (y > shownY) {
fill(RUNNER_X, shownY, RUNNER_W, y - shownY, C_SKY);
} else if (y < shownY) {
fill(RUNNER_X, (int16_t)(y + RUNNER_H), RUNNER_W, shownY - y, C_SKY);
}
drawRunner(pose, y);
shownY = y;
shownPose = pose;
}
// ---- coins
for (uint8_t i = 0; i < COINS; i++) {
if (!coins[i].live) {
continue;
}
drawCoin(coins[i].x, coins[i].y, false);
coins[i].x = (int16_t)(coins[i].x - d);
if (coins[i].x < -10) {
coins[i].live = false;
continue;
}
if (hits(RUNNER_X + 2, y, RUNNER_W - 4, RUNNER_H, coins[i].x, coins[i].y, 10, 10)) {
coins[i].live = false;
coinCount++;
sfx(SFX_COIN, LEN(SFX_COIN));
number(50, 280, coinCount, C_ACCENT, C_BG, 1, 4);
drawRunner(pose, y);
continue;
}
drawCoin(coins[i].x, coins[i].y, true);
}
// ---- the score, and faster as it climbs
rScore = distance / 8u + (uint32_t)coinCount * 25u;
if (frameNo % 5 == 0 && rScore != rShown) {
barScore((long)rScore);
rShown = rScore;
}
int32_t target = 48 + (int32_t)(distance / 600u) * 4;
speed = target > 112 ? 112 : target;
// ---- a crate under the runner ends it
for (uint8_t i = 0; i < CRATES; i++) {
if (!crates[i].live) {
continue;
}
if (hits(RUNNER_X + 4, y + 2, RUNNER_W - 8, RUNNER_H - 2, crates[i].x + 2,
(int16_t)(GROUND_Y - crates[i].h + 2), crates[i].w - 4, crates[i].h - 2)) {
barScore((long)rScore);
showGameOver(1, rScore);
return;
}
}
}
// =================================================================== SNAKE
const int8_t GRID_W = 18;
const int8_t GRID_H = 22;
const int16_t GRID_X = 12;
const int16_t GRID_Y = 36;
const uint16_t SNAKE_MAX = (uint16_t)GRID_W * GRID_H;
const uint16_t C_SNAKE = RGB(70, 210, 90);
const uint16_t C_SNAKE_DARK = RGB(30, 140, 50);
uint8_t sx[SNAKE_MAX];
uint8_t sy[SNAKE_MAX];
uint16_t sHead;
uint16_t sLen;
int8_t sdx, sdy;
int8_t queued[2][2];
uint8_t queuedCount;
uint8_t appleX, appleY;
uint32_t sStep;
uint32_t sLast;
uint32_t sScore;
static void snakeCell(uint8_t cx, uint8_t cy, uint8_t kind)
{
int16_t x = (int16_t)(GRID_X + cx * CELL);
int16_t y = (int16_t)(GRID_Y + cy * CELL);
if (kind == 0) {
fill(x, y, CELL, CELL, C_BG);
} else if (kind == 1) {
fill(x + 1, y + 1, CELL - 2, CELL - 2, C_SNAKE);
fill(x + 3, y + 3, CELL - 6, CELL - 6, C_SNAKE_DARK);
} else if (kind == 2) {
fill(x + 1, y + 1, CELL - 2, CELL - 2, C_SNAKE);
// two eyes, looking the way it goes
int16_t ex = (int16_t)(x + 5 + sdx * 2);
int16_t ey = (int16_t)(y + 5 + sdy * 2);
int16_t px = sdy != 0 ? 3 : 0;
int16_t py = sdx != 0 ? 3 : 0;
fill(ex - px, ey - py, 2, 2, C_TEXT);
fill(ex + px, ey + py, 2, 2, C_TEXT);
} else {
// the apple
fill(x, y, CELL, CELL, C_BG);
fill(x + 2, y + 3, 8, 8, C_RED);
fill(x + 1, y + 5, 10, 4, C_RED);
fill(x + 5, y, 2, 3, RGB(110, 70, 30));
fill(x + 7, y + 1, 3, 2, C_GREEN);
}
}
static bool onSnake(uint8_t cx, uint8_t cy)
{
for (uint16_t i = 0; i < sLen; i++) {
uint16_t at = (uint16_t)((sHead + SNAKE_MAX - i) % SNAKE_MAX);
if (sx[at] == cx && sy[at] == cy) {
return true;
}
}
return false;
}
static void placeApple()
{
do {
appleX = (uint8_t)random(GRID_W);
appleY = (uint8_t)random(GRID_H);
} while (onSnake(appleX, appleY));
snakeCell(appleX, appleY, 3);
}
static void snakeStart()
{
tft.fillScreen(C_BG);
drawBar("SNAKE");
frame(GRID_X - 3, GRID_Y - 3, GRID_W * CELL + 6, GRID_H * CELL + 6, C_EDGE, 2);
textCenter(306, "THE D-PAD STEERS", C_EDGE, C_BG, 1);
sLen = 4;
sHead = 3;
for (uint16_t i = 0; i < 4; i++) {
sx[i] = (uint8_t)(4 + i);
sy[i] = GRID_H / 2;
}
sdx = 1;
sdy = 0;
queuedCount = 0;
for (uint16_t i = 0; i < 3; i++) {
snakeCell(sx[i], sy[i], 1);
}
snakeCell(sx[3], sy[3], 2);
sStep = 150;
sLast = millis();
sScore = 0;
barScore(0);
placeApple();
stopMusic();
}
static void queueTurn(int8_t dx, int8_t dy)
{
// compare with the last direction queued, or the one it is going
int8_t lx = queuedCount ? queued[queuedCount - 1][0] : sdx;
int8_t ly = queuedCount ? queued[queuedCount - 1][1] : sdy;
if ((dx == lx && dy == ly) || (dx == -lx && dy == -ly) || queuedCount >= 2) {
return;
}
queued[queuedCount][0] = dx;
queued[queuedCount][1] = dy;
queuedCount++;
}
static void snakeUpdate()
{
if (pad.pressed(PAD_UP)) queueTurn(0, -1);
if (pad.pressed(PAD_DOWN)) queueTurn(0, 1);
if (pad.pressed(PAD_LEFT)) queueTurn(-1, 0);
if (pad.pressed(PAD_RIGHT)) queueTurn(1, 0);
uint32_t now = millis();
if (now - sLast < sStep) {
return;
}
sLast = now;
if (queuedCount > 0) {
sdx = queued[0][0];
sdy = queued[0][1];
queued[0][0] = queued[1][0];
queued[0][1] = queued[1][1];
queuedCount--;
}
int16_t nx = (int16_t)(sx[sHead] + sdx);
int16_t ny = (int16_t)(sy[sHead] + sdy);
bool eats = nx == appleX && ny == appleY;
// the tail moves out of the way this step unless the snake grows
uint16_t tail = (uint16_t)((sHead + SNAKE_MAX - (sLen - 1)) % SNAKE_MAX);
bool bites = false;
if (nx < 0 || ny < 0 || nx >= GRID_W || ny >= GRID_H) {
bites = true;
} else if (onSnake((uint8_t)nx, (uint8_t)ny)) {
bites = eats || !(sx[tail] == nx && sy[tail] == ny);
}
if (bites) {
showGameOver(2, sScore);
return;
}
snakeCell(sx[sHead], sy[sHead], 1);
if (!eats) {
snakeCell(sx[tail], sy[tail], 0);
} else {
sLen++;
}
sHead = (uint16_t)((sHead + 1) % SNAKE_MAX);
sx[sHead] = (uint8_t)nx;
sy[sHead] = (uint8_t)ny;
snakeCell((uint8_t)nx, (uint8_t)ny, 2);
if (eats) {
sScore += 10;
barScore((long)sScore);
sfx(SFX_EAT, LEN(SFX_EAT));
if (sStep > 70) {
sStep -= 4;
}
if (sLen < SNAKE_MAX) {
placeApple();
}
}
}
// ================================================================ the loop
static void startGame(uint8_t which)
{
paused = false;
over = false;
Serial.print("Play ");
Serial.println(NAMES[which]);
if (which == 0) {
screen = BLOCKS;
blocksStart();
} else if (which == 1) {
screen = RUNNER;
runnerStart();
} else {
screen = SNAKE;
snakeStart();
}
pad.consume();
}
static void togglePause()
{
paused = !paused;
sfx(SFX_PAUSE, LEN(SFX_PAUSE));
fill(60, 2, 70, 16, C_BAR);
if (paused) {
text(70, 7, "PAUSED", C_ACCENT, C_BAR, 1);
stopMusic();
Serial.println("Paused");
} else {
if (screen == BLOCKS) {
playMusic(BLOCKS_SONG, LEN(BLOCKS_SONG));
} else if (screen == RUNNER) {
playMusic(RUNNER_SONG, LEN(RUNNER_SONG));
}
// Lost time is not time to catch up on.
fallAt = millis();
sLast = millis();
Serial.println("Resumed");
}
}
static void splash()
{
tft.fillScreen(C_BG);
const char *logo = "MOKXI";
const uint16_t colors[5] = {RGB(255, 90, 90), RGB(255, 196, 40), RGB(80, 210, 110),
RGB(70, 170, 255), RGB(190, 110, 255)};
for (uint8_t i = 0; i < 5; i++) {
glyph((int16_t)(60 + i * 24), 130, logo[i], colors[i], colors[i], 4);
}
textCenter(170, "POCKET", C_TEXT, C_BG, 2);
sfx(SFX_BOOT, LEN(SFX_BOOT));
uint32_t until = millis() + 900;
while ((int32_t)(millis() - until) < 0) {
soundUpdate();
delay(10);
}
}
void setup()
{
Serial.begin(115200);
Serial.println("Mokxi Pocket: Blocks, Runner and Snake on an ILI9341");
noTone(PIEZO_PIN);
tft.begin(0);
splash();
// The pull-ups take a moment to lift the pins after begin(), so the very
// first reading is every button held. Let the pad settle before playing.
pad.begin();
for (uint8_t i = 0; i < 4; i++) {
delay(10);
pad.update();
}
pad.consume();
batteryUpdate();
goMenu();
}
void loop()
{
uint32_t frameStart = millis();
pad.update();
if (!seeded && pad.mask() != 0) {
// A person's reaction time is the one thing the board cannot predict.
randomSeed(micros());
seeded = true;
}
if (screen == MENU) {
menuUpdate();
} else if (over) {
if (millis() - overAt > 600 && (pad.pressed(PAD_A) || pad.pressed(PAD_START))) {
sfx(SFX_START, LEN(SFX_START));
startGame((uint8_t)(screen - 1));
} else if (pad.pressed(PAD_SELECT)) {
goMenu();
}
} else if (pad.pressed(PAD_SELECT)) {
goMenu();
} else if (pad.pressed(PAD_START)) {
togglePause();
} else if (!paused) {
if (screen == BLOCKS) {
blocksUpdate();
} else if (screen == RUNNER) {
runnerUpdate();
} else {
snakeUpdate();
}
}
soundUpdate();
batteryUpdate();
// A press is a press once: what was seen this frame is old news by the next.
pad.consume();
uint32_t spent = millis() - frameStart;
if (spent < FRAME_MS) {
delay(FRAME_MS - spent);
}
}
Code walkthrough
The key functions of the sketch, and what each one does.
loop sketch.ino
Each pass reads the pad, then decides what to do. On the menu it calls menuUpdate. In a game, Select goes back to the menu, Start pauses, and otherwise the current game's update runs. It then updates sound and battery, clears the one-shot presses, and waits so each frame lasts FRAME_MS, 25 milliseconds, about forty frames a second.
void loop()
{
uint32_t frameStart = millis();
pad.update();
if (!seeded && pad.mask() != 0) {
// A person's reaction time is the one thing the board cannot predict.
randomSeed(micros());
seeded = true;
}
if (screen == MENU) {
menuUpdate();
} else if (over) {
if (millis() - overAt > 600 && (pad.pressed(PAD_A) || pad.pressed(PAD_START))) {
sfx(SFX_START, LEN(SFX_START));
startGame((uint8_t)(screen - 1));
} else if (pad.pressed(PAD_SELECT)) {
goMenu();
}
} else if (pad.pressed(PAD_SELECT)) {
goMenu();
} else if (pad.pressed(PAD_START)) {
togglePause();
} else if (!paused) {
if (screen == BLOCKS) {
blocksUpdate();
} else if (screen == RUNNER) {
runnerUpdate();
// ... 14 more lines in the editorsoundUpdate sketch.ino
Music and sound effects are lists of notes with lengths. This function walks them by the clock using millis, so nothing blocks. If an effect is playing it wins the piezo for a moment, while the tune keeps its place underneath. It only calls tone or noTone when the wanted pitch changes, and silence wins when sound is off.
static void soundUpdate()
{
uint32_t now = millis();
uint16_t tune = trackPitch(music, now);
uint16_t want = effect.notes != nullptr ? trackPitch(effect, now) : tune;
if (effect.notes == nullptr && want == 0) {
want = tune;
}
if (!soundOn) {
want = 0;
}
if (want != sounding) {
if (want != 0) {
tone(PIEZO_PIN, want);
} else {
noTone(PIEZO_PIN);
}
sounding = want;
}
}batteryUpdate sketch.ino
Once a second it reads pin 3 with analogReadMilliVolts and doubles the result, because the two 100k resistors halve the cell. A reading below 60 means no battery, so the icon says USB. Otherwise 3300 mV counts as 0 percent and 4200 mV as 100 percent. It also prints changes to the serial monitor.
static void batteryUpdate()
{
uint32_t now = millis();
if (now - batteryAt >= 1000 || batteryAt == 0) {
batteryAt = now;
int raw = analogRead(BATTERY_PIN);
int8_t percent;
if (raw < 60) {
percent = -1;
batteryMv = 0;
} else {
// The divider halves the cell. The raw reading is not linear (a C3
// saturates at about 2.9 V), so ask for calibrated millivolts.
batteryMv = (uint16_t)(analogReadMilliVolts(BATTERY_PIN) * 2u);
long p = ((long)batteryMv - 3300) * 100 / 900;
percent = (int8_t)(p < 0 ? 0 : (p > 100 ? 100 : p));
}
if (percent / 5 != batteryPercent / 5 || (percent < 0) != (batteryPercent < 0)) {
if (percent < 0) {
Serial.println("Battery: none, running on USB");
} else {
Serial.print("Battery: ");
Serial.print(batteryMv / 1000.0, 2);
Serial.print(" V, ");
Serial.print(percent);
Serial.println("%");
}
}
// ... 4 more lines in the editorsnakeUpdate sketch.ino
Presses on the D-pad queue up turns. Only when sStep milliseconds have passed does the snake move one cell. It checks for walls and its own body, then paints just three cells: the old head becomes body, the new head appears, and the tail is erased unless an apple was eaten. Each apple adds 10 points and speeds the snake up.
static void snakeUpdate()
{
if (pad.pressed(PAD_UP)) queueTurn(0, -1);
if (pad.pressed(PAD_DOWN)) queueTurn(0, 1);
if (pad.pressed(PAD_LEFT)) queueTurn(-1, 0);
if (pad.pressed(PAD_RIGHT)) queueTurn(1, 0);
uint32_t now = millis();
if (now - sLast < sStep) {
return;
}
sLast = now;
if (queuedCount > 0) {
sdx = queued[0][0];
sdy = queued[0][1];
queued[0][0] = queued[1][0];
queued[0][1] = queued[1][1];
queuedCount--;
}
int16_t nx = (int16_t)(sx[sHead] + sdx);
int16_t ny = (int16_t)(sy[sHead] + sdy);
bool eats = nx == appleX && ny == appleY;
// the tail moves out of the way this step unless the snake grows
uint16_t tail = (uint16_t)((sHead + SNAKE_MAX - (sLen - 1)) % SNAKE_MAX);
bool bites = false;
if (nx < 0 || ny < 0 || nx >= GRID_W || ny >= GRID_H) {
bites = true;
} else if (onSnake((uint8_t)nx, (uint8_t)ny)) {
bites = eats || !(sx[tail] == nx && sy[tail] == ny);
// ... 27 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The screen stays dark or shows nothing This project
Check the four SPI wires (CS 18, DC 19, SCK 20, MOSI 21) and that VCC, RST and LED are all on 3V3. - A button does nothing or a direction is swapped This project
Each button needs one side on its pin and the other on ground. The pad pins are UP 6, DOWN 5, LEFT 4, RIGHT 10, A 1, B 0, Start 2 and Select 9. - The battery icon says USB even with a cell attached This project
The gauge needs the cell's plus through a 100k resistor to pin 3, then another 100k to ground. A reading under 60 counts as no cell. - 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. - Buttons read the wrong way around, or float. Game console buttons
These are switches to ground. Set each pin to INPUT_PULLUP and treat LOW as pressed. Without the pull-up the pin floats. - One press registers several times. Game console buttons
Mechanical buttons bounce for a few milliseconds. Wait about 5 to 20 ms before reading again, or use a debounce library. - 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.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.