Anirudha Gaikwad
Published © MIT

4-Color E-Paper Driver Custom PCB

I wanted a wristwatch with a colour E-paper so I built a 4-Color E-Paper Driver Board for any MCU

IntermediateFull instructions provided5 hours46
4-Color E-Paper Driver Custom PCB

Things used in this project

Hardware components

1 µF/10 V on the rail pins
×11
C12 as the charge-pump flying cap
×1
C11 100 nF decoupler
×1
CT1 220 µF/1210 bulk
×1
R1 10 kΩ gate resistor on GDR
×1
R2 0.47 Ω current sense on RESE
×1
DL2 10 µH WE-MAPI boost inductor
×1
DL1 BLM18PG181SH1D ferrite bead
×1
B0540WS-TP
×3
SI1304BDL
×1
DJ1 FPC - 24 Pin Top Mount FPC
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

NextPCB PCBA

Story

Read more

Schematics

Schematic

Code

Pacman Frame printing

Arduino
// ---------------------------------------------------------------------------
//  Pacman_Reel  --  ONE static, arcade-accurate Pac-Man frame on 4-color e-paper
//
//  Panel : GDEM0154F51H (200x200, black/white/red/yellow, controller JD79660)
//  Board : ESP32-C3
//
//  The scene is built once; loop() pushes that same image to the panel again
//  every PUSH_INTERVAL_MS (5 s). Nothing on screen changes between pushes --
//  it is the identical frame, re-driven.
//
//  WHAT THAT ACTUALLY LOOKS LIKE. A 4-color Spectra refresh is a long
//  multi-pass waveform (~12-20 s) that shuffles the red and yellow pigment,
//  so the panel spends most of its time mid-refresh, strobing through the
//  colour passes, and the real cycle is (refresh + 5 s), not 5 s. The wait is
//  measured and printed each pass. If what you want is a still frame to
//  photograph, the panel is bistable and holds the image with no power at all:
//  set PUSH_ONCE below and it draws one frame and hibernates instead.
//
//  WHAT IS ON SCREEN
//
//  The real arcade playfield, tile for tile: 28 x 31 tiles of the original
//  Pac-Man maze (the exact map, all 240 pellets + 4 energizers), drawn at
//  6 px per tile = 168 x 186 px, bottom-aligned, with the score row in the
//  14 px strip above it. Arcade tiles are 8 px, so this is the same maze at
//  3/4 scale; sprites are 11 px (arcade sprite cells are 16 px with ~13 px
//  of ink, so ~10 px here).
//
//  COLOR SUBSTITUTION.  The panel has black/white/red/yellow only, so every
//  arcade color that does not exist maps to the nearest one that does:
//
//      background  black   ->  black      (exact)
//      maze walls  blue    ->  white      (no blue; white keeps the contrast)
//      pellets     peach   ->  yellow     (reads as the classic dot)
//      Pac-Man     yellow  ->  yellow     (exact)
//      Blinky      red     ->  red        (exact)
//      Pinky       pink    ->  red        (nearest)
//      Inky        cyan    ->  white      (nearest; its eyes are inverted)
//      Clyde       orange  ->  yellow     (nearest)
//      house door  pink    ->  red        (nearest)
//
//  THE MOMENT.  Not the level-start pose: a real mid-game frame. Pac-Man has
//  run the route right along the row-23 corridor, down the right side and
//  back along the bottom row; every pellet on that route is gone and the
//  score in the HUD is computed from exactly those pellets (10 each), so the
//  number on screen matches the board. Blinky is four tiles behind him in the
//  bottom corridor, Pinky is cutting him off one corridor up, Inky is coming
//  down the left side, Clyde has just left the ghost house.
// ---------------------------------------------------------------------------

#include <GxEPD2_4C.h>
#include <epd4c/GxEPD2_154c_GDEM0154F51H.h>
#include <math.h>

// Pin definitions (e-paper) -- same wiring as Epaper_4Color.ino
#define EPD_MOSI 7
#define EPD_SCK  6
#define EPD_CS   10
#define EPD_DC   3
#define EPD_RST  20
#define EPD_BUSY 4

GxEPD2_4C<GxEPD2_154c_GDEM0154F51H, GxEPD2_154c_GDEM0154F51H::HEIGHT>
    display(GxEPD2_154c_GDEM0154F51H(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));

// ---------------------------------------------------------------------------
//  Geometry
// ---------------------------------------------------------------------------
static const int16_t SCR_W = 200;
static const int16_t SCR_H = 200;

static const int16_t COLS  = 28;
static const int16_t ROWS  = 31;
static const int16_t TILE  = 6;                            // arcade tile is 8 px
static const int16_t OX    = (SCR_W - COLS * TILE) / 2;    // 16
static const int16_t OY    = SCR_H - ROWS * TILE;          // 14, maze sits low

// Wall line weight. The arcade draws walls as 2 px outlines on an 8 px tile,
// i.e. 1.5 px here. 1 is the faithful one: blocks stay hollow and the outer
// border comes out as the arcade's double line. Raise to 2 for a bolder maze
// if single pixels come out too faint on your panel (the double line then
// closes up into one solid band).
static const int16_t WALL_TH = 1;

static const uint32_t HIGH_SCORE = 31160;   // the arcade shows a stored high score

// Gap between pushes, measured from the end of one refresh to the start of the
// next. The refresh itself is ~12-20 s on top of this.
static const uint32_t PUSH_INTERVAL_MS = 5000;

// Set true to go back to one frame and hibernate (the panel holds the image
// with no power, so it stays on screen and never flashes).
static const bool PUSH_ONCE = false;

// ---------------------------------------------------------------------------
//  The maze
//
//  The original Pac-Man map, 28 x 31 tiles.
//    '#' wall   '.' pellet   'o' energizer   '-' ghost-house door   ' ' floor
//  Mutable: the route walk below eats the pellets Pac-Man has already taken.
// ---------------------------------------------------------------------------
static char g_maze[ROWS][COLS + 1] = {
    "############################",   //  0
    "#............##............#",   //  1
    "#.####.#####.##.#####.####.#",   //  2
    "#o####.#####.##.#####.####o#",   //  3  energizers
    "#.####.#####.##.#####.####.#",   //  4
    "#..........................#",   //  5
    "#.####.##.########.##.####.#",   //  6
    "#.####.##.########.##.####.#",   //  7
    "#......##....##....##......#",   //  8
    "######.##### ## #####.######",   //  9
    "     #.##### ## #####.#     ",   // 10
    "     #.##          ##.#     ",   // 11  the perch above the house
    "     #.## ###--### ##.#     ",   // 12  ghost-house door
    "######.## #      # ##.######",   // 13
    "          #      #          ",   // 14  tunnel row, open at both edges
    "######.## #      # ##.######",   // 15
    "     #.## ######## ##.#     ",   // 16
    "     #.##          ##.#     ",   // 17
    "     #.## ######## ##.#     ",   // 18
    "######.## ######## ##.######",   // 19
    "#............##............#",   // 20
    "#.####.#####.##.#####.####.#",   // 21
    "#.####.#####.##.#####.####.#",   // 22
    "#o..##................##..o#",   // 23  energizers, Pac-Man starts here
    "###.##.##.########.##.##.###",   // 24
    "###.##.##.########.##.##.###",   // 25
    "#......##....##....##......#",   // 26
    "#.##########.##.##########.#",   // 27
    "#.##########.##.##########.#",   // 28
    "#..........................#",   // 29
    "############################",   // 30
};

// Off-grid counts as floor so the outer border also gets its outside line --
// the arcade border is a double line. The exception is the two rows bounding
// the tunnel: there the corridor runs off the screen edge and must stay open.
static bool isWall(int16_t r, int16_t c)
{
    if (r < 0 || r >= ROWS) return false;
    if (c < 0 || c >= COLS) return (r == 13 || r == 15);
    return g_maze[r][c] == '#';
}

static inline int16_t tileX(int16_t c) { return OX + c * TILE; }
static inline int16_t tileY(int16_t r) { return OY + r * TILE; }

// Pixel centre of a point given in tile units, where a whole number is a tile
// EDGE: tile (c,r) centres on (c + 0.5, r + 0.5) and a sprite sitting between
// two columns centres on a whole number -- the convention the arcade uses.
static inline int16_t spriteX(float c) { return OX + (int16_t)lroundf(c * TILE); }
static inline int16_t spriteY(float r) { return OY + (int16_t)lroundf(r * TILE); }

// ---------------------------------------------------------------------------
//  Where everything is
//
//  The route Pac-Man has already run, in tile coordinates. Every pellet on it
//  is eaten and scores 10 (an energizer would score 50); he ends on the last
//  waypoint, still heading left.
// ---------------------------------------------------------------------------
struct Tile { int8_t c, r; };

static const Tile ROUTE[] = {
    { 13, 23 },   // start, between the two centre columns of the bottom half
    { 21, 23 },   // right along the long corridor
    { 21, 26 },   // down the right side
    { 26, 26 },
    { 26, 29 },
    { 10, 29 },   // back left along the bottom row -- he is here now
};
static const uint8_t ROUTE_N = sizeof(ROUTE) / sizeof(ROUTE[0]);

static const float PAC_C = 10.5f, PAC_R = 29.5f;   // head of the route

struct GhostPos { float c, r; uint16_t color; int8_t lookX, lookY; };

static const GhostPos GHOSTS[4] = {
    { 14.5f, 29.5f, GxEPD_RED,    -1,  0 },   // Blinky, right behind him
    {  9.5f, 26.5f, GxEPD_RED,    -1,  0 },   // Pinky, cutting him off above
    {  6.5f, 20.5f, GxEPD_WHITE,   0,  1 },   // Inky, coming down the left
    { 14.0f, 11.5f, GxEPD_YELLOW, -1,  0 },   // Clyde, just out of the house
};

static uint32_t eatRoute()
{
    uint32_t score = 0;
    int8_t   c = ROUTE[0].c, r = ROUTE[0].r;

    for (uint8_t i = 0; i < ROUTE_N; i++) {
        const int8_t tc = ROUTE[i].c, tr = ROUTE[i].r;
        const int8_t dc = (tc > c) - (tc < c);
        const int8_t dr = (tr > r) - (tr < r);
        for (;;) {
            char& t = g_maze[r][c];
            if      (t == '.') { t = ' '; score += 10; }
            else if (t == 'o') { t = ' '; score += 50; }
            if (c == tc && r == tr) break;
            c += dc;
            r += dr;
        }
    }
    return score;
}

// ---------------------------------------------------------------------------
//  Sprites
// ---------------------------------------------------------------------------

// Yellow disc with a wedge bitten out, facing dirDeg (180 = left; screen y
// grows downwards). No outline -- the arcade sprite has none.
static void drawPacman(int16_t cx, int16_t cy, int16_t r, float dirDeg, float halfDeg)
{
    display.fillCircle(cx, cy, r, GxEPD_YELLOW);
    if (halfDeg <= 0.0f) return;

    const float a = dirDeg  * (float)PI / 180.0f;
    const float h = halfDeg * (float)PI / 180.0f;
    const float R = r + 2.0f;               // overshoot so the mouth cuts clean through
    display.fillTriangle(cx, cy,
                         cx + (int16_t)(R * cosf(a - h)), cy + (int16_t)(R * sinf(a - h)),
                         cx + (int16_t)(R * cosf(a + h)), cy + (int16_t)(R * sinf(a + h)),
                         GxEPD_BLACK);
}

// 11 x 11: domed head, square body, the three-footed skirt, and eyes that look
// where the ghost is heading. Corridors are 6 px and sprites are 11, so a ghost
// overlaps the walls it passes -- the arcade does exactly the same (16 px
// sprites in 8 px corridors). The white-bodied ghost would dissolve into the
// white walls, so it gets a one-pixel black halo and inverted eyes.
static void drawGhost(int16_t cx, int16_t cy, uint16_t body, int8_t lookX, int8_t lookY)
{
    const int16_t x = cx - 5, y = cy - 5;

    if (body == GxEPD_WHITE) {
        display.fillCircle(cx, y + 5, 6, GxEPD_BLACK);
        display.fillRect(x - 1, y + 5, 13, 7, GxEPD_BLACK);
    }

    display.fillCircle(cx, y + 5, 5, body);
    display.fillRect(x, y + 5, 11, 6, body);
    // The dome's own bottom row would fill these, so punch the skirt back out.
    display.fillRect(x + 3, y + 9, 1, 2, GxEPD_BLACK);
    display.fillRect(x + 7, y + 9, 1, 2, GxEPD_BLACK);

    const uint16_t sclera = (body == GxEPD_WHITE) ? GxEPD_BLACK : GxEPD_WHITE;
    const uint16_t pupil  = (body == GxEPD_WHITE) ? GxEPD_WHITE : GxEPD_BLACK;
    const int16_t  px     = (lookX > 0) ? 1 : 0;
    const int16_t  py     = (lookY > 0) ? 1 : 0;

    display.fillRect(x + 2, y + 3, 3, 4, sclera);
    display.fillRect(x + 6, y + 3, 3, 4, sclera);
    display.fillRect(x + 2 + px, y + 4 + py, 2, 2, pupil);
    display.fillRect(x + 6 + px, y + 4 + py, 2, 2, pupil);
}

// Level-1 fruit. Its stem is green in the arcade; yellow is the nearest here.
static void drawCherry(int16_t cx, int16_t cy)
{
    display.fillCircle(cx - 3, cy + 2, 3, GxEPD_RED);
    display.fillCircle(cx + 2, cy + 3, 3, GxEPD_RED);
    display.drawPixel(cx - 4, cy + 1, GxEPD_WHITE);
    display.drawPixel(cx + 1, cy + 2, GxEPD_WHITE);
    display.drawLine(cx - 3, cy - 1, cx + 1, cy - 5, GxEPD_YELLOW);
    display.drawLine(cx + 2, cy,     cx + 1, cy - 5, GxEPD_YELLOW);
}

// ---------------------------------------------------------------------------
//  Maze rendering
//
//  Arcade walls are outlines, not slabs: a wall tile only gets a line on the
//  sides that face open floor. A two-tile-thick block therefore comes out
//  hollow, and the one-tile outer border comes out as the classic double line.
// ---------------------------------------------------------------------------
static void drawMaze()
{
    for (int16_t r = 0; r < ROWS; r++) {
        for (int16_t c = 0; c < COLS; c++) {
            const char    t = g_maze[r][c];
            const int16_t x = tileX(c), y = tileY(r);

            if (t == '-') {                     // ghost-house door
                display.fillRect(x, y + TILE / 2 - 1, TILE, 2, GxEPD_RED);
                continue;
            }
            if (t != '#') continue;

            if (!isWall(r - 1, c))
                display.fillRect(x, y + 1, TILE, WALL_TH, GxEPD_WHITE);
            if (!isWall(r + 1, c))
                display.fillRect(x, y + TILE - 1 - WALL_TH, TILE, WALL_TH, GxEPD_WHITE);
            if (!isWall(r, c - 1))
                display.fillRect(x + 1, y, WALL_TH, TILE, GxEPD_WHITE);
            if (!isWall(r, c + 1))
                display.fillRect(x + TILE - 1 - WALL_TH, y, WALL_TH, TILE, GxEPD_WHITE);
        }
    }
}

static void drawPellets()
{
    for (int16_t r = 0; r < ROWS; r++) {
        for (int16_t c = 0; c < COLS; c++) {
            const char    t = g_maze[r][c];
            const int16_t x = tileX(c), y = tileY(r);
            if (t == '.') {
                display.fillRect(x + 2, y + 2, 2, 2, GxEPD_YELLOW);
            } else if (t == 'o') {
                display.fillCircle(x + TILE / 2, y + TILE / 2, 3, GxEPD_YELLOW);
            }
        }
    }
}

// Score row above the maze, plus lives and fruit. The arcade puts those two on
// a row below the maze; a square panel has no room for one, so they go in the
// side margins that the portrait playfield leaves free.
static void drawHud(uint32_t score)
{
    char buf[12];

    display.setTextSize(1);              // built-in 6x8 font, closest to the arcade one
    display.setTextColor(GxEPD_WHITE);

    display.setCursor(OX, 3);
    display.print("1UP");

    sprintf(buf, "%lu", (unsigned long)score);
    display.setCursor(64 - (int16_t)strlen(buf) * 6, 3);
    display.print(buf);

    display.setCursor(70, 3);
    display.print("HIGH SCORE");

    sprintf(buf, "%lu", (unsigned long)HIGH_SCORE);
    display.setCursor(SCR_W - OX - (int16_t)strlen(buf) * 6, 3);
    display.print(buf);

    drawPacman(7, 174, 5, 180.0f, 40.0f);   // two lives still in hand
    drawPacman(7, 188, 5, 180.0f, 40.0f);
    drawCherry(192, 186);
}

static void drawScene(uint32_t score)
{
    display.fillScreen(GxEPD_BLACK);

    drawMaze();
    drawPellets();
    drawHud(score);

    for (uint8_t i = 0; i < 4; i++) {
        const GhostPos& g = GHOSTS[i];
        drawGhost(spriteX(g.c), spriteY(g.r), g.color, g.lookX, g.lookY);
    }

    drawPacman(spriteX(PAC_C), spriteY(PAC_R), 5, 180.0f, 40.0f);
}

// ---------------------------------------------------------------------------
//  Setup / loop
// ---------------------------------------------------------------------------

// The eaten pellets and the score they add up to are worked out once, in
// setup(): eatRoute() clears the pellets it counts, so a second call would
// find an already-eaten board and return 0. Every push after that re-draws
// this same board with this same score.
static uint32_t g_score = 0;
static uint32_t g_pushes = 0;

static void pushFrame()
{
    const uint32_t started = millis();

    display.setFullWindow();
    display.firstPage();
    do {
        drawScene(g_score);
    } while (display.nextPage());

    g_pushes++;
    Serial.printf("push %lu : refresh %lu ms\n",
                  (unsigned long)g_pushes, (unsigned long)(millis() - started));
}

void setup()
{
    Serial.begin(115200);
    delay(2000);

    Serial.println();
    Serial.println("Pac-Man : GDEM0154F51H 4-color");

    // E-paper uses SCK + MOSI only (no MISO).
    SPI.begin(EPD_SCK, -1, EPD_MOSI, -1);
    display.init(115200, true, 10, false);
    display.setRotation(0);

    g_score = eatRoute();
    Serial.printf("score on screen: %lu\n", (unsigned long)g_score);

    if (PUSH_ONCE) {
        pushFrame();
        display.hibernate();     // bistable: the image stays put with no power
        Serial.println("frame held - hibernating, nothing further will be drawn");
        return;
    }

    Serial.printf("pushing the same frame every %lu ms + refresh time\n",
                  (unsigned long)PUSH_INTERVAL_MS);
}

void loop()
{
    if (PUSH_ONCE) { delay(1000); return; }

    pushFrame();
    delay(PUSH_INTERVAL_MS);
}

Credits

Anirudha Gaikwad
5 projects • 5 followers
Hobbyist , Content based on Robotics, electronics, PCB designing.

Comments