■ 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

CPURISC-V 160 MHz
Flash4 MB
RAM400 KB
WiFi802.11 b/g/n
Size22.5 × 18 mm
📺

0.96” SSD1306 OLED

Resolution128 × 64 px
Colours1-bit (white)
InterfaceI²C
Address0x3C
Voltage3.3 V / 5 V

Wiring

The OLED connects over I²C — just four wires. On the ESP32-C3 SuperMini, I used the default I²C pins:

ESP32-C3 SuperMini 3V3 GND GPIO6 (SDA) GPIO7 (SCL) SSD1306 OLED 0.96" 128×64 VCC GND SDA SCL power ground data clock
📷 Photo of the assembled hardware coming soon

Architecture

Before writing code, I mapped out the software layers:

🎮 State Machine IDLE → WALKING → SLEEPING → HAPPY — drives all creature behaviour
🌟 Sprite Engine Pixel-art frames stored as byte arrays, rendered with frame cycling
🔒 Display Driver U8g2 library talking to SSD1306 over I²C — handles the 128×64 buffer
⏱ Timer Loop 20 ms tick (50 fps) — updates animation frame, checks state transitions
📖 PlatformIO / Arduino Build system targeting esp32-c3 with Arduino framework

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:

■ CubeWorld — ESP32-C3 OLED (128×64) IDLE

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:

IDLE
WALKING
SLEEPING
HAPPY

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

C++ — sprite data in flash
// 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,
  }
};
C++ — drawing a sprite
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

C++ — 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

C++ — main loop (20 ms tick)
#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

📷 Photo of the finished cube on the desk — coming soon
🎬 Video of the creature walking and sleeping — coming soon
Total cost: ESP32-C3 SuperMini (~$3) + SSD1306 OLED (~$2) + small LiPo battery + a 3D-printed cube enclosure. Under $10 for a desktop companion that actually runs your own code.
Next steps: I want to add a small accelerometer (MPU-6050) so the creature reacts to being shaken or tilted — the one feature that made the original CubeWorld special. The I²C bus already has room for a second device.

Full source on GitHub — link coming once I clean up the code.