■ CubeWorld on an ESP32-C3
CubeWorld was a
2005 toy by Radica Games:
tiny LCD cubes each
containing a stick-figure
creature that reacted to
movement, connected
to neighbouring cubes.
I do not know if you have heard of CubeWorld. It is a tiny device that displays small animated characters living inside a cube — they walk, sleep, play, and react when you interact with them. Think Tamagotchi, but open-world and chain-able across multiple cubes.
I had one as a kid. They have been discontinued for years, and the original hardware is essentially impossible to find. So I decided to build my own — using an ESP32-C3 microcontroller and a small OLED display. Since I ended up spending a few weekends on this and learned a lot along the way, here is the full write-up.
What Is CubeWorld?
The original Radica cubes
connected magnetically.
Walk your character to
the edge and it would
“jump” into the next cube.
My version is a single
cube, but that is enough.
CubeWorld is a tiny interactive desktop companion living on a small screen. The original device was a 2005 toy — a plastic cube with a 1-bit LCD display showing a stick-figure creature that reacted to tilt, taps, and neighbouring cubes.
What makes it interesting to re-implement:
- Sprite engine — pixel art characters with multi-frame animations
- State machine — creature has moods and transitions between them
- Input handling — buttons and/or accelerometer for interaction
- Tiny display — 128×64 OLED with 1-bit colour, every pixel matters
Hardware
ESP32-C3 SuperMini
0.96” SSD1306 OLED
Wiring
The OLED connects over I²C — just four wires. On the ESP32-C3 SuperMini, I used the default I²C pins:
Architecture
Before writing code, I mapped out the software layers:
Live Simulator
This is a faithful
recreation of what runs
on the actual device.
The pixel grid is 128×64
— exactly the OLED
resolution. Each white
square is one pixel.
The creature below is the same character running on the real hardware. Press the buttons to interact — just like pushing the physical button on the cube:
State Machine
Every state has a
duration (how long the
creature stays in it)
and transitions (what
it does next). Randomness
in the duration makes
the creature feel alive.
Click any state to learn about its behaviour and transitions:
The Sprite System
The entire visual system is built around byte arrays stored in flash memory. Each character is 10×16 pixels; each row is one byte (10 bits used):
Animation frames — click to inspect
// Each row = 10 pixels packed into a uint16_t (left = MSB) // PROGMEM stores in flash instead of precious RAM static const uint16_t SPRITE_IDLE[2][16] PROGMEM = { { /* frame 0 — eyes open */ 0b0111111110, // ████████ head top 0b1000000001, // █ █ 0b1010000101, // █ ■ ■ █ eyes 0b1000000001, 0b1001001001, // █ ■ ■ ■ █ smile 0b0111111110, // ████████ head bot 0b0001111000, // ████ neck 0b0111111110, // ████████ body 0b1000000001, 0b1000000001, 0b0111111110, 0b0001001000, // █ █ legs 0b0001001000, 0b0011001100, // ██ ██ feet 0b0000000000, 0b0000000000, }, { /* frame 1 — eyes closed (blink) */ 0b0111111110, 0b1000000001, 0b1011100111, // █ ■■■ ■■ █ closed eyes 0b1000000001, 0b1001001001, 0b0111111110, 0b0001111000, 0b0111111110, 0b1000000001, 0b1000000001, 0b0111111110, 0b0001001000, 0b0001001000, 0b0011001100, 0b0000000000, 0b0000000000, } };
void drawSprite( U8G2& u8g2, const uint16_t frame[16], int x, int y, bool flipH = false ) { for (int row = 0; row < 16; row++) { uint16_t bits = pgm_read_word(&frame[row]); for (int col = 0; col < 10; col++) { int bit = flipH ? col : (9 - col); if (bits & (1 << bit)) { u8g2.drawPixel(x + col, y + row); } } } }
The State Machine
enum class State { IDLE, WALKING, SLEEPING, HAPPY }; struct Creature { State state = State::IDLE; int x = 59; // centre of 128px display int dir = 1; // 1 = right, -1 = left int frame = 0; uint32_t stateMs = 0; // when we entered this state uint32_t stateDur = 3000;// how long to stay (ms) int happiness = 80; // 0-100 void update(uint32_t now) { if (now - stateMs > stateDur) transition(now); switch (state) { case State::WALKING: x = constrain(x + dir, 5, 113); if (x <= 5 || x >= 113) dir = -dir; break; default: break; } } void transition(uint32_t now) { // randomly pick next state based on happiness int r = random(100); if (happiness < 30) state = State::SLEEPING; else if (r < 40) state = State::WALKING; else if (r < 70) state = State::IDLE; else state = State::HAPPY; stateMs = now; stateDur = 2000 + random(4000); // 2–6 s of variability frame = 0; } void feed() { happiness = min(100, happiness + 20); } void pet() { happiness = min(100, happiness + 10); state = State::HAPPY; } void sleep() { state = State::SLEEPING; } };
Main Loop
#include <U8g2lib.h> #include <Wire.h> U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2( U8G2_R0, U8X8_PIN_NONE, /* SCL= */ 7, /* SDA= */ 6 ); Creature creature; void setup() { u8g2.begin(); u8g2.setContrast(200); randomSeed(analogRead(0)); } void loop() { uint32_t now = millis(); creature.update(now); u8g2.clearBuffer(); // draw floor line u8g2.drawHLine(0, 56, 128); // draw creature const uint16_t* frame = getFrame(creature); drawSprite(u8g2, frame, creature.x, 40, creature.dir < 0); // draw sleep ZZZ if sleeping if (creature.state == State::SLEEPING) { u8g2.setFont(u8g2_font_4x6_tr); u8g2.drawStr(creature.x + 12, 38, "z"); u8g2.drawStr(creature.x + 16, 32, "z"); u8g2.drawStr(creature.x + 20, 26, "Z"); } u8g2.sendBuffer(); delay(20); // 50 fps }
Result
Full source on GitHub — link coming once I clean up the code.