Two P10 32×16 1/4-Scan Panels → 32×32 Football LED Scoreboard with ESP8266 Wi-Fi Web Control

Hello everyone!

I’d like to share a DIY football (soccer) scoreboard project I’ve been working on with the help of Claude AI. The project is designed for a small stadium or football academy.

The display uses two P10 RGB 32×16 LED panels (1/4 scan) connected together to create a 32×32 LED scoreboard. The system is controlled by an ESP8266, with a DFPlayer Mini used for voice announcements and referee whistle sounds.

:soccer_ball: Main Features

  • 32×32 LED scoreboard using two P10 RGB 32×16 1/4-scan panels
  • ESP8266 as the main controller
  • Wi-Fi web control — control the scoreboard directly from a phone
  • Live match clock with minutes and seconds
  • Start / Pause / Reset controls
  • Change the score for both teams
  • Red team vs. Green team display
  • Animated waving flags
  • Goal celebration screen with team name and updated score
  • Kickoff and full-time voice announcements
  • Referee whistle sounds
  • Custom scrolling text messages
  • Different match states such as waiting → kickoff → running/paused → full-time
  • Non-blocking code so the display, web interface, audio and animations can work together without freezing

:mobile_phone: Wi-Fi Web Control

One of the main parts of the project is the web control interface hosted by the ESP8266.

A phone connected to the same Wi-Fi network can be used to control the scoreboard without additional physical buttons.

The web interface allows me to:

  • :play_button: Start the match
  • :pause_button: Pause the match
  • :counterclockwise_arrows_button: Reset the match
  • :stopwatch: Control the match time
  • :red_circle::green_circle: Change the score
  • :memo: Send custom scrolling text
  • Control the different match screens and states

For example, this is the web interface I built:

[Insert web-interface screenshot here]

:electric_plug: Panel Wiring / Chaining

Stage 1 — ESP8266 → Panel #1 input


D0 → LAT
D1 → A
D2 → B
D4 → OE
D5 → CLK
D7 → R1

Stage 2 — Panel #1 output → Panel #2 input


R1  → G1
G1  → R1
B1  → B1
G2  → R2
R2  → G2
B2  → B2
A   → A
B   → B
C   → C
D   → D
LAT → LAT
CLK → CLK
OE  → OE
GND → GND

Stage 3 — Panel #1 input → Panel #2 output


R2 → G1
G2 → R1
B2 → B1
G1 → R2
B1 → G2

:warning: Current Color-Mapping Issue

There is currently an interesting color-mapping problem when the two panels are used together.

The colors are consistently mapped incorrectly, rather than changing randomly.

For example:

  • Green always appears as blue.
  • Red appears split between red and green.
  • Other colors appear split between pink and sky blue, rather than displaying the intended color correctly.

So I'm currently trying to understand whether this is related to the RGB signal mapping, panel configuration, scan type, or the way the two P10 panels are chained.

I'm posting the project because I'd like to hear from anyone who has worked with similar P10 RGB panels, 1/4-scan panels, ESP8266, or unusual RGB color mapping/chaining and might recognize this behavior.

:puzzle_piece: Project Files and Code

[AudioPlayer.cpp|attachment](upload://jgICl5prRgwJOKdqOixKEhsUxa9.cpp) (1.5 KB)
[AudioPlayer.h|attachment](upload://AgKFrkko1byh17DJKg5mO3NGjZf.h) (2.0 KB)
[Display.cpp|attachment](upload://exvlUc78llmJo64z7WIOC91iJTT.cpp) (31.6 KB)
[Display.h|attachment](upload://sG8ncqurJOaPidufULCwLry72wD.h) (4.8 KB)
[GameState.cpp|attachment](upload://gaBBQ0W7jThzhHtSownwuSiMyCa.cpp) (9.5 KB)
[GameState.h|attachment](upload://rtXcDIflabKADfCF78sbKwznTH6.h) (3.9 KB)
[ScoreboardProject.ino|attachment](upload://4oI1vWm4Fk3aSxCbGDbTIRmZv9d.ino) (16.8 KB)
[WebServer.cpp|attachment](upload://9Evv5gjkN0n72iRBPDrFKpnIJDC.cpp) (26.1 KB)
[WebServer.h|attachment](upload://ykHNvmS6lpEfGctCKx5SjVGT8EK.h) (364 Bytes)

These files make up the complete Arduino project.
AudioPlayer.cpp (1.5 KB)
AudioPlayer.h (2.0 KB)
Display.cpp (31.6 KB)
Display.h (4.8 KB)
GameState.cpp (9.5 KB)
GameState.h (3.9 KB)
ScoreboardProject.ino (16.8 KB)
WebServer.cpp (26.1 KB)
WebServer.h (364 Bytes)

Hopefully, this project can be useful to anyone building a similar P10 football scoreboard, stadium display, football academy scoreboard, or ESP8266-controlled LED project.

Since it appears that your 2nd P10 is swapping Green for Red compared to the first, you may want to correct the connection here to Rx -> Rx & Gx -> Gx

It might be better to fix this on a software basis. That means just alter the main color order of the panel.

I've tried multiple methods, including changing the wiring, adjusting the code, and other things, but the results are inconsistent. Sometimes there's heavy flickering or poor color, and this current wiring setup gives the best result so far.

The latter. It looks like you have an incorrect connection between the two panels.

The library designed to be connected the way, where every P1 output pin is connected to corresponding P2 INPUT as one-to-one:

R1  → R1
G1  → G1
B1  → B1
R2  → R2
G2  → G2
B2  → B2
A   → A
B   → B
C   → C
D   → D
LAT → LAT
CLK → CLK
OE  → OE
GND → GND

To connect output P1 and input P2, you can use the standard 16-pin ribbon cable that typically comes with these panels.

and for P2 OUTPUT to P1 INPUT:

R1  → G1
G1  → B1
B1  → R2
R2  → G2
G2  → B2

Also, keep in mind that the code must be configured for your 1/4 scan panel

Thanks for the suggestion. I tried that exact wiring, but the colors still don't match between the two panels. With a single panel, colors display correctly. But when I chain the two panels, I see mismatched colors like red turning blue or green appearing on one panel." "So even with this wiring, I'm still not getting consistent colors across both panels.

Also, with this wiring, I have even more problems when displaying text. Sometimes I can't get the text to appear correctly at all, and I get a lot of other issues. That’s why I tried many different wirings and checked the connections several times to see if I could get a better result. So far, this current wiring gives me the best result I've been able to get, even though the colors are still incorrect. I'm not sure if there's another wiring or configuration I could try.

Another important thing I found is that I need to use a 74LS245 between the ESP8266 and the P10 panel." "Without the 74LS245, I get a lot of flickering on the panel." "Using the 74LS245 or a similar level shifter gives much better and more stable results without the flickering.

If I understand correctly, you were simply trying various random wire connection combinations in an attempt to get the correct display output. The main problem with this approach is the huge number of possible combinations. When you additionally take into account the wide variety of "1/4 scan" panel types, it becomes clear that the chances of finding the right configuration through random trial and error are slim.
To find the solution, you need to understand that the connection of each pin follows specific rules defined in the library code.

Could be an idea to switch to a 74HCT245.

The main question is why your are using it and why you have omitted this part of the information so far.

Now i don't really want to go into details about your code, since i am not just going to download it and look for the relevant part. If the color anomalies are consistent there should be no reason why you can not manually correct the color order for certain parts of the display.

I do wonder of they are actually exactly the same panel or if maybe they come out of a different batch.

Well i haven't.

so instead of RGB it is xBx

and there it is either RBG or GBR. (Back to the question of them coming from the same batch as well) Those are very common color orders for LEDs

So if you can provide me with an example of how 'any' pixel from your code is addressed, then either we can modify the code, or if there are calls from library member functions to another member function we can modify the library.

So i guess display.h and display.cpp, but please don't post them as download links but rather as code between <code> tags.

I only use the 74LS245 after finishing my code, mainly to reduce flickering on the panels. Before that, I wired the ESP8266 directly to the panel. I used the 74LS245 only because I couldn't find the recommended 74HCT245.

From my testing, that chip didn't affect the color mapping or fix the color problems.

One more detail about the mapping: whenever I tried changing the wiring or mapping between the two panels, the result would change. Sometimes the panels would show different colors, sometimes one panel would light up while the other stayed blank, and sometimes the color behavior would change again. So I'm getting different color and display issues depending on how the panels are connected. That's why I tried many combinations, but I still couldn't determine the correct mapping for these panels.

#include "Display.h"
#include <string.h>
#include <math.h>

#ifdef ESP8266
  #include <Ticker.h>
  static Ticker display_ticker;
#endif

// =====================================================================
// Panel object + refresh ISR (unchanged in spirit from the single-panel
// version - the panel is refreshed on a timer independent of everything
// below).
// =====================================================================

PxMATRIX display(MATRIX_WIDTH, MATRIX_HEIGHT, P_LAT, P_OE, P_A, P_B, P_C);

static uint8_t display_draw_time = 40;

uint16_t COLOR_RED, COLOR_GREEN, COLOR_BLUE, COLOR_WHITE;
uint16_t COLOR_YELLOW, COLOR_CYAN, COLOR_MAGENTA, COLOR_BLACK;

#ifdef ESP8266
static void display_updater()
{
  display.display(display_draw_time);
}
#endif

static void display_update_enable(bool enable)
{
#ifdef ESP8266
  if (enable)
    display_ticker.attach_ms(2, display_updater);
  else
    display_ticker.detach();
#endif
}

// =====================================================================
// Virtual-canvas / rotation / two-panel-split machinery. Everything
// below this point draws onto vCanvas (and vCanvasColon where a second
// color is needed) in normal left-to-right/top-to-bottom terms, then one
// of the push*() functions maps that onto the two physical panels,
// compensating for the panel unit being physically turned 90 degrees.
// Ported as-is from the working single-file scoreboard sketch.
// =====================================================================

#define panel_width 32
#define vcanvas_w 32
#define vcanvas_h 32

static GFXcanvas1 vCanvas(vcanvas_w, vcanvas_h);
static GFXcanvas1 vCanvasColon(vcanvas_w, vcanvas_h);

const bool ROTATE_CW = true;

// Maps a physical-buffer position (vx across a panel, vy down the two
// stacked halves) back to the upright canvas pixel it displays.
static void rotatedSourceXY(int16_t vx, int16_t vy, int16_t &srcX, int16_t &srcY)
{
  if (ROTATE_CW)
  {
    srcX = vy;
    srcY = (vcanvas_w - 1) - vx;
  }
  else
  {
    srcX = (vcanvas_h - 1) - vy;
    srcY = vx;
  }
}

static bool getRotatedPixelFrom(GFXcanvas1 &canvas, int16_t vx, int16_t vy)
{
  int16_t srcX, srcY;
  rotatedSourceXY(vx, vy, srcX, srcY);
  return canvas.getPixel(srcX, srcY);
}

static void pushVirtualCanvasToDisplay(uint16_t color)
{
  display.clearDisplay();
  for (int16_t vy = 0; vy < vcanvas_h; vy++)
  {
    for (int16_t vx = 0; vx < vcanvas_w; vx++)
    {
      if (getRotatedPixelFrom(vCanvas, vx, vy))
      {
        int16_t physX = (vy < 16 ? 0 : panel_width) + vx;
        int16_t physY = (vy < 16 ? vy : vy - 16);
        display.drawPixel(physX, physY, color);
      }
    }
  }
}

// Clock push: the 4 big digits sit in a 2x2 grid on the upright canvas
// (minutes on the top row, seconds on the bottom row, tens on the left,
// ones on the right), so each digit's color is chosen by which quadrant
// the lit pixel is in. The colon dots (vCanvasColon) get their own color.
static void pushClockToDisplay(uint16_t minTensColor, uint16_t minOnesColor,
                               uint16_t secTensColor, uint16_t secOnesColor,
                               uint16_t colonColor)
{
  display.clearDisplay();
  for (int16_t vy = 0; vy < vcanvas_h; vy++)
  {
    for (int16_t vx = 0; vx < vcanvas_w; vx++)
    {
      int16_t physX = (vy < 16 ? 0 : panel_width) + vx;
      int16_t physY = (vy < 16 ? vy : vy - 16);
      int16_t srcX, srcY;
      rotatedSourceXY(vx, vy, srcX, srcY);

      if (vCanvas.getPixel(srcX, srcY))
      {
        bool bottomRow = srcY >= 16;
        bool rightCol  = srcX >= 16;
        uint16_t color = bottomRow ? (rightCol ? secOnesColor : secTensColor)
                                   : (rightCol ? minOnesColor : minTensColor);
        display.drawPixel(physX, physY, color);
      }
      else if (vCanvasColon.getPixel(srcX, srcY))
      {
        display.drawPixel(physX, physY, colonColor);
      }
    }
  }
}

// Color-pixel plotting (for the multi-color scoreboard - flags/poles/
// digits/star all differently colored in the same frame). Same
// coordinate convention as vCanvas (normal x=column, y=row), rotated and
// split the same way.
static void plotColorPixel(int16_t srcX, int16_t srcY, uint16_t color)
{
  int16_t vx, vy;
  if (ROTATE_CW)
  {
    vy = srcX;
    vx = (vcanvas_w - 1) - srcY;
  }
  else
  {
    vy = (vcanvas_h - 1) - srcX;
    vx = srcY;
  }
  if (vx < 0 || vx >= vcanvas_w || vy < 0 || vy >= vcanvas_h) return;

  int16_t physX = (vy < 16 ? 0 : panel_width) + vx;
  int16_t physY = (vy < 16 ? vy : vy - 16);
  display.drawPixel(physX, physY, color);
}

static void plotColorRect(int16_t srcX, int16_t srcY, int16_t w, int16_t h, uint16_t color)
{
  for (int16_t j = 0; j < h; j++)
    for (int16_t i = 0; i < w; i++)
      plotColorPixel(srcX + i, srcY + j, color);
}

void displayInit()
{
  display.begin(4);
  display.setScanPattern(ZAGZIG);

  COLOR_RED     = display.color565(255, 0, 0);
  COLOR_GREEN   = display.color565(0, 255, 0);
  COLOR_BLUE    = display.color565(0, 0, 255);
  COLOR_WHITE   = display.color565(255, 255, 255);
  COLOR_YELLOW  = display.color565(255, 255, 0);
  COLOR_CYAN    = display.color565(0, 255, 255);
  COLOR_MAGENTA = display.color565(255, 0, 255);
  COLOR_BLACK   = display.color565(0, 0, 0);

  display.clearDisplay();
  display.setTextWrap(false);
  display.setRotation(0); // leave library rotation off - we rotate content ourselves above
  display_update_enable(true);
}

// =====================================================================
// Non-blocking countdown timer - single big number, centered.
// =====================================================================

static bool          cd_running = false;
static uint8_t        cd_current = 0;
static unsigned long  cd_lastTick = 0;

static void countdown_drawFrame()
{
  char buf[4];
  snprintf(buf, sizeof(buf), "%d", cd_current);

  int16_t x1, y1;
  uint16_t w, h;
  vCanvas.setTextSize(3);
  vCanvas.getTextBounds(buf, 0, 0, &x1, &y1, &w, &h);

  vCanvas.fillScreen(0);
  vCanvas.setCursor((vcanvas_w - (int)w) / 2, (vcanvas_h - (int)h) / 2);
  vCanvas.setTextColor(1);
  vCanvas.print(buf);

  pushVirtualCanvasToDisplay(COLOR_MAGENTA);
}

void countdownBegin(uint8_t seconds)
{
  cd_current = seconds;
  cd_lastTick = millis();
  cd_running = (seconds > 0);
  if (cd_running) countdown_drawFrame();
}

bool countdownIsRunning() { return cd_running; }

bool countdownUpdate()
{
  if (!cd_running) return false;

  if (millis() - cd_lastTick >= 1000)
  {
    cd_lastTick += 1000;
    cd_current--;
    if (cd_current == 0)
    {
      cd_running = false;
      return true;
    }
    countdown_drawFrame();
  }
  return false;
}

// =====================================================================
// Non-blocking scrolling text, used for the welcome / kickoff /
// end-of-match messages: right-to-left, always green, drawn at text
// size 2 (the standard 5x7 font doubled = 2-LED strokes, letters 14 rows
// tall), vertically centered, with a 1-LED red border around the whole
// panel while it is showing. Written as a Begin/Update/IsRunning state
// machine (one step per call, no delay()) so it doesn't block the web
// server / audio / serial handling in the main loop.
// yPos/frameDelayMs/r/g/b are accepted for API compatibility with the
// caller but intentionally ignored.
//
// Spacing: the built-in font is fixed-width, which leaves big gaps around
// narrow letters (i, l, comma...) and a 12-LED-wide space. Instead each
// glyph's real width is measured and the letters are packed with exactly
// LETTER_GAP_PX LEDs between them; a space adds SPACE_ADVANCE_PX on top of
// the letter gap, so words end up WORD_GAP_PX (= 6) LEDs apart.
//
// Speed: one step every SCROLL_FRAME_MS (90 ms), each step moves 2 LEDs
// (one "big pixel" of the doubled font). Lower the number to speed the
// text up, raise it to slow it down.
//
// Chevrons: while a message is showing, rows of yellow 2-LED-thick
// chevrons stream out of the center along the top and bottom edges -
// "<" going left on the left half, ">" going right on the right half -
// one LED per step, appearing from the center seam and sliding off the
// outer edges.
// =====================================================================

static bool           sc_running = false;
static int             sc_x = 0;
static int             sc_endX = 0;
static int             sc_y = 0;
static unsigned long   sc_lastFrame = 0;
static String          sc_text;
static unsigned int    sc_phase = 0;   // chevron animation step counter

static const unsigned long SCROLL_FRAME_MS = 90; // one step per 90 ms (was 40, then 55, then 70 - slowed for easy reading)...
static const int SCROLL_STEP_PX = 2;             // ...of 2 LEDs (text is drawn at 2x)
static const int SCROLL_TEXT_Y = 8;              // capitals rows 8-21, descenders to row 23 -> whole text block rows 8-23, exactly centered
static const int LETTER_GAP_PX = 1;              // LEDs between letters
static const int SPACE_ADVANCE_PX = 5;           // + LETTER_GAP_PX = 6 LEDs between words

// Real (unspaced) pixel columns each character uses in the built-in 5x7
// font, measured once per character by drawing it into a scratch canvas.
static GFXcanvas1 glyphProbe(8, 8);
static int8_t glyphLeft[128];   // first used column (0-4)
static int8_t glyphCols[128];   // number of columns from first to last used
static bool   glyphMeasured[128];

static void measureGlyph(unsigned char ch)
{
  if (ch >= 128 || glyphMeasured[ch]) return;
  glyphProbe.fillScreen(0);
  glyphProbe.drawChar(0, 0, ch, 1, 1, 1); // bg == color -> transparent
  int8_t left = -1, right = -1;
  for (int8_t col = 0; col < 6; col++)
    for (int8_t row = 0; row < 8; row++)
      if (glyphProbe.getPixel(col, row))
      {
        if (left < 0) left = col;
        right = col;
      }
  glyphLeft[ch] = (left < 0) ? 0 : left;
  glyphCols[ch] = (left < 0) ? 0 : (right - left + 1);
  glyphMeasured[ch] = true;
}

// Anything outside plain printable ASCII (custom messages can contain
// whatever was typed) is drawn as '?' so the glyph tables are never
// indexed out of range.
static unsigned char printableChar(unsigned char ch)
{
  return (ch >= 32 && ch <= 126) ? ch : '?';
}

static int scrollTextWidth(const String &t)
{
  int w = 0;
  for (unsigned i = 0; i < t.length(); i++)
  {
    unsigned char ch = printableChar((unsigned char)t[i]);
    if (ch == ' ') { w += SPACE_ADVANCE_PX; continue; }
    measureGlyph(ch);
    w += glyphCols[ch] * 2 + LETTER_GAP_PX;
  }
  return w;
}

// 1-LED red frame around the whole 32x32 panel (drawn last so it sits on
// top of the text as it slides in and out at the edges).
static void drawRedBorder()
{
  plotColorRect(0,  0,  32, 1,  COLOR_RED);
  plotColorRect(0,  31, 32, 1,  COLOR_RED);
  plotColorRect(0,  1,  1,  30, COLOR_RED);
  plotColorRect(31, 1,  1,  30, COLOR_RED);
}

// Yellow chevron streams along the top (rows 1-6) and bottom (rows 25-30)
// edges. Each chevron is 4 wide x 6 tall with 2-LED strokes; CHEVRON_PITCH
// LEDs apart. Left chevrons are clipped to cols <= 15 and right ones to
// cols >= 16, so they seem to come out of the center line.
static const int CHEVRON_PITCH = 6;
static const char *const chevronLeft[6] = { "..##", ".##.", "##..", "##..", ".##.", "..##" };

static void drawChevronStream(int topY)
{
  int offset = sc_phase % CHEVRON_PITCH;
  for (int o = offset; o < 16 + 4; o += CHEVRON_PITCH)
  {
    int xLeft = 16 - o; // glyph's left column; starts hidden right of the seam, slides left
    for (int r = 0; r < 6; r++)
      for (int col = 0; col < 4; col++)
      {
        if (chevronLeft[r][col] != '#') continue;

        int xl = xLeft + col;              // left-going "<"
        if (xl >= 0 && xl <= 15)
          plotColorPixel(xl, topY + r, COLOR_YELLOW);

        int xr = 31 - xl;                  // mirror image: right-going ">"
        if (xr >= 16 && xr <= 31)
          plotColorPixel(xr, topY + r, COLOR_YELLOW);
      }
  }
}

static void scroll_drawFrame()
{
  vCanvas.fillScreen(0);

  int x = sc_x;
  for (unsigned i = 0; i < sc_text.length() && x < vcanvas_w; i++)
  {
    unsigned char ch = printableChar((unsigned char)sc_text[i]);
    if (ch == ' ') { x += SPACE_ADVANCE_PX; continue; }
    measureGlyph(ch);
    int gw = glyphCols[ch] * 2;
    if (x + gw > 0) // skip letters that are entirely off the left edge
      vCanvas.drawChar(x - glyphLeft[ch] * 2, sc_y, ch, 1, 1, 2); // bg == color -> transparent
    x += gw + LETTER_GAP_PX;
  }

  pushVirtualCanvasToDisplay(COLOR_GREEN);
  drawChevronStream(1);
  drawChevronStream(25);
  drawRedBorder();
}

void scrollTextBegin(uint8_t yPos, unsigned long frameDelayMs, const String &text,
                      uint8_t r, uint8_t g, uint8_t b)
{
  (void)yPos; (void)frameDelayMs; (void)r; (void)g; (void)b; // see note above - all ignored on purpose
  sc_text = text;

  sc_y = SCROLL_TEXT_Y;
  sc_x = vcanvas_w;
  sc_endX = -scrollTextWidth(text);
  sc_lastFrame = millis();
  sc_phase = 0;
  sc_running = true;

  scroll_drawFrame();
}

bool scrollTextIsRunning() { return sc_running; }

bool scrollTextUpdate()
{
  if (!sc_running) return false;

  unsigned long now = millis();
  if (now - sc_lastFrame >= SCROLL_FRAME_MS)
  {
    sc_lastFrame = now;
    sc_x -= SCROLL_STEP_PX;
    sc_phase++;
    if (sc_x <= sc_endX)
    {
      sc_running = false;
      return true;
    }
    scroll_drawFrame();
  }
  return false;
}

// =====================================================================
// Small color-aware 7-segment digit (7x16), for the scoreboard's score
// readout - ported from the flag-scoreboard sketch, unchanged.
// =====================================================================

static const uint8_t sevenSegPatterns[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

static void drawSevenSegDigitColor(int16_t x0, int16_t y0, uint8_t digit, uint16_t color)
{
  if (digit > 9) return;

  // The "1" uses the same shape as the big clock's 1 (little flag at the
  // top-left, straight stem, foot at the bottom) drawn with 2-LED strokes,
  // instead of the plain two-bar seven-segment 1.
  if (digit == 1)
  {
    static const char *const one[16] = {
      "...##..",
      "..###..",
      ".####..",
      "...##..", "...##..", "...##..", "...##..", "...##..", "...##..",
      "...##..", "...##..", "...##..", "...##..", "...##..",
      ".#####.",
      ".#####."
    };
    for (uint8_t r = 0; r < 16; r++)
      for (uint8_t col = 0; col < 7; col++)
        if (one[r][col] == '#') plotColorPixel(x0 + col, y0 + r, color);
    return;
  }

  uint8_t seg = sevenSegPatterns[digit];

  bool hasA = seg & 0x01, hasB = seg & 0x02, hasC = seg & 0x04, hasD = seg & 0x08;
  bool hasE = seg & 0x10, hasF = seg & 0x20, hasG = seg & 0x40;

  int upperY = hasA ? (y0 + 2) : y0;
  int upperH = hasA ? 5 : 7;
  int lowerY = y0 + 9;
  int lowerH = hasD ? 5 : 7;

  if (hasA) plotColorRect(x0,     y0,      7, 2, color);
  if (hasF) plotColorRect(x0,     upperY,  2, upperH, color);
  if (hasB) plotColorRect(x0 + 5, upperY,  2, upperH, color);
  if (hasG) plotColorRect(x0,     y0 + 7,  7, 2, color);
  if (hasE) plotColorRect(x0,     lowerY,  2, lowerH, color);
  if (hasC) plotColorRect(x0 + 5, lowerY,  2, lowerH, color);
  if (hasD) plotColorRect(x0,     y0 + 14, 7, 2, color);

  if (!hasG)
  {
    if (hasB && hasC) plotColorRect(x0 + 5, y0 + 7, 2, 2, color);
    if (hasF && hasE) plotColorRect(x0,     y0 + 7, 2, 2, color);
  }

  // Chamfer: shave 1 LED off each of the 4 outer corners for a clean,
  // non-blocky look - same treatment used on the other digit fonts.
  plotColorPixel(x0,     y0,      COLOR_BLACK);
  plotColorPixel(x0 + 6, y0,      COLOR_BLACK);
  plotColorPixel(x0,     y0 + 15, COLOR_BLACK);
  plotColorPixel(x0 + 6, y0 + 15, COLOR_BLACK);
}

// One or two digits (score 0-99), centered horizontally inside the free
// columns [areaLeftX .. areaRightX] of one panel half, and sitting at the
// bottom of the canvas with 1 blank LED row underneath (digit rows 15-30).
static void drawScoreDigits(int areaLeftX, int areaRightX, uint8_t score, uint16_t color)
{
  const int digitY = 15; // 16-row digit -> rows 15..30, row 31 left blank
  if (score > 99) score = 99;

  int areaW = areaRightX - areaLeftX + 1;
  if (score < 10)
  {
    int x0 = areaLeftX + (areaW - 7) / 2;
    drawSevenSegDigitColor(x0, digitY, score, color);
  }
  else
  {
    int x0 = areaLeftX + (areaW - 14) / 2;
    drawSevenSegDigitColor(x0,     digitY, (score / 10) % 10, color);
    drawSevenSegDigitColor(x0 + 7, digitY, score % 10,        color);
  }
}

// =====================================================================
// Scoreboard: flags/poles/star exactly as our existing design/colors,
// now driven by real scores. Self-throttled to redraw every ~150ms so
// it's safe to call every loop() iteration, same pattern the original
// single-panel drawScoreboard() used.
// =====================================================================

static void drawWavyFlag(int x0, int y0, int flagW, int flagH, int attach_col, int star_dx, int star_dy,
                         float phase, uint16_t bodyColor, uint16_t starColor)
{
  for (int col = 0; col < flagW; col++)
  {
    int shift = (col == attach_col) ? 0 : (int)round(sin((col * 0.9) + phase));
    plotColorRect(x0 + col, y0 + shift, 1, flagH, bodyColor);
  }
  int cx = x0 + star_dx, cy = y0 + star_dy;
  plotColorPixel(cx,     cy - 1, starColor);
  plotColorPixel(cx - 1, cy,     starColor);
  plotColorPixel(cx,     cy,     starColor);
  plotColorPixel(cx + 1, cy,     starColor);
  plotColorPixel(cx,     cy + 1, starColor);
}

void drawScoreboard(uint8_t redScore, uint8_t greenScore, uint16_t scoreColor)
{
  (void)scoreColor; // score digits are always red now, regardless of what's passed in - see below

  static unsigned long lastFrameMs = 0;
  static float wavePhase = 0;
  unsigned long now = millis();
  if (now - lastFrameMs < 150) return;
  lastFrameMs = now;
  wavePhase += 0.6;

  // BUG FIX: this was never clearing before redrawing, so as the flag's
  // wave animation shifted pixels frame to frame, the previous frame's
  // pixels never turned off - they just kept accumulating, which is
  // what was causing the messy/flickery buildup.
  display.clearDisplay();

  const int poleTop = 1;
  const int poleBottom = 31;
  const int poleHeight = poleBottom - poleTop + 1;
  const int flagW = 11, flagH = 9;

  plotColorRect(0, poleTop, 1, poleHeight, COLOR_GREEN);
  drawWavyFlag(1, poleTop, flagW, flagH, /*attach_col=*/0, /*star_dx=*/5, /*star_dy=*/3, wavePhase, COLOR_RED, COLOR_GREEN);

  drawWavyFlag(20, poleTop, flagW, flagH, /*attach_col=*/flagW - 1, /*star_dx=*/5, /*star_dy=*/3, wavePhase, COLOR_RED, COLOR_GREEN);
  plotColorRect(31, poleTop, 1, poleHeight, COLOR_GREEN);

  // Red team's score on the left panel, green team's on the right -
  // centered in each half, along the bottom of the board.
  // Always red, per request - the scoreColor argument is intentionally
  // ignored now (kept in the signature for API compatibility).
  drawScoreDigits(1, 15, redScore, COLOR_RED);    // left half: between pole (col 0) and centre marks (col 15)
  drawScoreDigits(17, 30, greenScore, COLOR_RED); // right half: between centre marks (col 16) and pole (col 31)

  plotColorRect(15, 20, 2, 2, COLOR_GREEN);
  plotColorRect(15, 26, 2, 2, COLOR_GREEN);
}

// =====================================================================
// Match timer / full-time flash - the bigger clock-style font (13x14),
// minutes on top, seconds on bottom, colon between, ported from the
// same clock-digit work already validated on this rig.
// =====================================================================

static const uint8_t clockSevenSegPatterns[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

static const char* const clockDigitBitmap1[14] = {
  "......###....", ".....####....", "....#####....", "......###....",
  "......###....", "......###....", "......###....", "......###....",
  "......###....", "......###....", "......###....", "......###....",
  "....######...", "....######..."
};

static void drawClockDigitBitmap1(GFXcanvas1 &canvas, int x0, int y0)
{
  for (int row = 0; row < 14; row++)
    for (int col = 0; col < 13; col++)
      if (clockDigitBitmap1[row][col] == '#') canvas.drawPixel(x0 + col, y0 + row, 1);
}

static const char* const clockDigitBitmap7[14] = {
  ".############", ".############", "..........###", ".........###.",
  "........###..", ".......###...", "......###....", ".....###.....",
  "....###......", "....###......", "....###......", "....###......",
  "....###......", "....###......"
};

static void drawClockDigitBitmap7(GFXcanvas1 &canvas, int x0, int y0)
{
  for (int row = 0; row < 14; row++)
    for (int col = 0; col < 13; col++)
      if (clockDigitBitmap7[row][col] == '#') canvas.drawPixel(x0 + col, y0 + row, 1);
}

static void drawClockSevenSegDigit(GFXcanvas1 &canvas, int x0, int y0, uint8_t digit)
{
  if (digit > 9) return;
  uint8_t seg = clockSevenSegPatterns[digit];
  bool hasA = seg & 0x01, hasB = seg & 0x02, hasC = seg & 0x04, hasD = seg & 0x08;
  bool hasE = seg & 0x10, hasF = seg & 0x20, hasG = seg & 0x40;

  int upperY = hasA ? (y0 + 2) : y0;
  int upperH = hasA ? 4 : 6;
  int lowerY = y0 + 8;
  int lowerH = hasD ? 4 : 6;

  if (hasA) canvas.fillRect(x0,      y0,      13, 2, 1);
  if (hasF) canvas.fillRect(x0,      upperY,   3, upperH, 1);
  if (hasB) canvas.fillRect(x0 + 10, upperY,   3, upperH, 1);
  if (hasG) canvas.fillRect(x0,      y0 + 6,  13, 2, 1);
  if (hasE) canvas.fillRect(x0,      lowerY,   3, lowerH, 1);
  if (hasC) canvas.fillRect(x0 + 10, lowerY,   3, lowerH, 1);
  if (hasD) canvas.fillRect(x0,      y0 + 12, 13, 2, 1);

  if (!hasG)
  {
    if (hasB && hasC) canvas.fillRect(x0 + 10, y0 + 6, 3, 2, 1);
    if (hasF && hasE) canvas.fillRect(x0,      y0 + 6, 3, 2, 1);
  }

  if (hasA && hasF) canvas.drawPixel(x0,      y0,      0);
  if (hasA && hasB) canvas.drawPixel(x0 + 12, y0,      0);
  if (hasD && hasE) canvas.drawPixel(x0,      y0 + 13, 0);
  if (hasD && hasC) canvas.drawPixel(x0 + 12, y0 + 13, 0);

  if (hasG && !hasF) canvas.drawPixel(x0,      y0 + 6, 0);
  if (hasG && !hasB) canvas.drawPixel(x0 + 12, y0 + 6, 0);
  if (hasG && !hasE) canvas.drawPixel(x0,      y0 + 7, 0);
  if (hasG && !hasC) canvas.drawPixel(x0 + 12, y0 + 7, 0);
}

static void drawClockDigit(GFXcanvas1 &canvas, int x0, int y0, uint8_t digit)
{
  if (digit == 1) drawClockDigitBitmap1(canvas, x0, y0);
  else if (digit == 7) drawClockDigitBitmap7(canvas, x0, y0);
  else drawClockSevenSegDigit(canvas, x0, y0, digit);
}

static void showClockBigNumber(int minutes, int seconds)
{
  minutes = minutes % 100;
  seconds = seconds % 100;
  uint8_t d[4] = {
    (uint8_t)((minutes / 10) % 10), (uint8_t)(minutes % 10),
    (uint8_t)((seconds / 10) % 10), (uint8_t)(seconds % 10)
  };

  const int digit_h = 14;
  int row1_y = (16 - digit_h) / 2;
  int row2_y = 16 + (16 - digit_h) / 2;

  vCanvas.fillScreen(0);
  vCanvasColon.fillScreen(0);

  drawClockDigit(vCanvas, 0,  row1_y, d[0]);
  drawClockDigit(vCanvas, 19, row1_y, d[1]);
  drawClockDigit(vCanvas, 0,  row2_y, d[2]);
  drawClockDigit(vCanvas, 19, row2_y, d[3]);

  vCanvasColon.fillRect(13, 15, 2, 2, 1);
  vCanvasColon.fillRect(17, 15, 2, 2, 1);

  // Digit colors by position: minutes-tens red, minutes-ones yellow,
  // seconds-tens yellow, seconds-ones red; colon dots green.
  pushClockToDisplay(COLOR_RED, COLOR_YELLOW, COLOR_YELLOW, COLOR_RED, COLOR_GREEN);
}

void drawMatchTimer(uint16_t elapsedSeconds)
{
  showClockBigNumber(elapsedSeconds / 60, elapsedSeconds % 60);
}

void drawFullTimeFlash(uint16_t finalElapsedSeconds)
{
  showClockBigNumber(finalElapsedSeconds / 60, finalElapsedSeconds % 60);
}

// =====================================================================
// Referee screen - "REF" in green, sized to fill the panel exactly:
// each pixel of the built-in 5x7 font becomes 2 LEDs wide x 3 LEDs tall,
// so every letter is 10 x 21 LEDs and 3 letters + 2 one-LED gaps = 32
// columns. The glyph shapes are read from the built-in font through the
// scratch canvas used by the scroller (glyphProbe).
// =====================================================================

void drawReferee()
{
  vCanvas.fillScreen(0);

  const char *word = "REF";
  const int cellW = 2, cellH = 3;      // LEDs per font pixel
  const int letterW = 5 * cellW;       // 10
  const int gap = 1;
  const int total = 3 * letterW + 2 * gap; // 32
  const int x0 = (vcanvas_w - total) / 2;
  const int y0 = (vcanvas_h - 7 * cellH) / 2; // rows 5..25

  for (int i = 0; i < 3; i++)
  {
    glyphProbe.fillScreen(0);
    glyphProbe.drawChar(0, 0, (unsigned char)word[i], 1, 1, 1);
    for (int col = 0; col < 5; col++)
      for (int row = 0; row < 7; row++)
        if (glyphProbe.getPixel(col, row))
          vCanvas.fillRect(x0 + i * (letterW + gap) + col * cellW, y0 + row * cellH, cellW, cellH, 1);
  }

  pushVirtualCanvasToDisplay(COLOR_GREEN);
}

// =====================================================================
// "WAITING FOR START" - static, three centered lines with 1-LED-thick
// strokes (letters 6 rows tall), one color per line - WAITING red, FOR
// green, START yellow - inside a 1-LED red border around the whole panel.
// Hand-drawn glyphs plotted through plotColorPixel() so they use the same
// rotation / two-panel mapping as everything else.
// =====================================================================

struct ThinGlyph
{
  char ch;
  uint8_t w;
  const char *rows[6];
};

static const ThinGlyph thinGlyphs[] = {
  { 'W', 5, { "#...#", "#...#", "#...#", "#.#.#", "#.#.#", ".#.#." } },
  { 'A', 3, { ".#.", "#.#", "#.#", "###", "#.#", "#.#" } },
  { 'I', 1, { "#", "#", "#", "#", "#", "#" } },
  { 'T', 3, { "###", ".#.", ".#.", ".#.", ".#.", ".#." } },
  { 'N', 4, { "#..#", "##.#", "##.#", "#.##", "#.##", "#..#" } },
  { 'G', 3, { ".##", "#..", "#..", "#.#", "#.#", ".##" } },
  { 'F', 3, { "###", "#..", "##.", "#..", "#..", "#.." } },
  { 'O', 3, { ".#.", "#.#", "#.#", "#.#", "#.#", ".#." } },
  { 'R', 3, { "##.", "#.#", "#.#", "##.", "#.#", "#.#" } },
  { 'S', 3, { ".##", "#..", "#..", ".#.", "..#", "##." } },
};

static const ThinGlyph *findThinGlyph(char ch)
{
  for (uint8_t i = 0; i < sizeof(thinGlyphs) / sizeof(thinGlyphs[0]); i++)
    if (thinGlyphs[i].ch == ch) return &thinGlyphs[i];
  return nullptr;
}

// One word centered horizontally with its top row at `top`, 1 LED between letters.
static void drawThinLine(const char *word, int16_t top, uint16_t color)
{
  uint8_t n = strlen(word);
  int16_t total = 0;
  for (uint8_t i = 0; i < n; i++)
  {
    const ThinGlyph *g = findThinGlyph(word[i]);
    if (g) total += g->w + (i < n - 1 ? 1 : 0);
  }

  int16_t x = (vcanvas_w - total) / 2;
  for (uint8_t i = 0; i < n; i++)
  {
    const ThinGlyph *g = findThinGlyph(word[i]);
    if (!g) continue;
    for (uint8_t r = 0; r < 6; r++)
      for (uint8_t col = 0; col < g->w; col++)
        if (g->rows[r][col] == '#') plotColorPixel(x + col, top + r, color);
    x += g->w + 1;
  }
}

void drawWaitingForStart()
{
  display.clearDisplay();

  // Inside the 1-LED border (rows 1-30): three 6-row lines with 3 blank
  // rows above, between and below them -> tops at rows 4 / 13 / 22.
  drawThinLine("WAITING", 4,  COLOR_RED);
  drawThinLine("FOR",     13, COLOR_GREEN);
  drawThinLine("START",   22, COLOR_YELLOW);

  drawRedBorder();
}

// =====================================================================
// Goal celebration - static two-half layout, refreshed ~7x/second only so
// the flag keeps waving.
//
//   GREEN team scores:  left half = flag (pole on the left edge) + the
//                       new score; right half = GOAL / FOR / GREEN / TEAM
//   RED team scores:    mirror image - text on the left half, flag (pole
//                       on the right edge) + new score on the right half
//
// Text is yellow, 1-LED strokes (3x6 letters, 4 wide for M/N). The flag
// half is drawn exactly like the scoreboard's: same flag (red, green
// star), same green pole, same position next to the pole, red score.
// =====================================================================

struct SmallGlyph
{
  char ch;
  uint8_t w;
  const char *rows[6];
};

static const SmallGlyph goalGlyphs[] = {
  { 'G', 3, { ".##", "#..", "#..", "#.#", "#.#", ".##" } },
  { 'O', 3, { ".#.", "#.#", "#.#", "#.#", "#.#", ".#." } },
  { 'A', 3, { ".#.", "#.#", "#.#", "###", "#.#", "#.#" } },
  { 'L', 3, { "#..", "#..", "#..", "#..", "#..", "###" } },
  { 'F', 3, { "###", "#..", "##.", "#..", "#..", "#.." } },
  { 'R', 3, { "##.", "#.#", "#.#", "##.", "#.#", "#.#" } },
  { 'E', 3, { "###", "#..", "##.", "#..", "#..", "###" } },
  // 'e' = 2-wide E, used only inside "GREEN" to keep that word as narrow as possible.
  { 'e', 2, { "##", "#.", "##", "#.", "#.", "##" } },
  { 'D', 3, { "##.", "#.#", "#.#", "#.#", "#.#", "##." } },
  { 'T', 3, { "###", ".#.", ".#.", ".#.", ".#.", ".#." } },
  { 'N', 4, { "#..#", "##.#", "##.#", "#.##", "#.##", "#..#" } },
  { 'M', 4, { "#..#", "####", "####", "#..#", "#..#", "#..#" } },
};

static const SmallGlyph *findGoalGlyph(char ch)
{
  for (uint8_t i = 0; i < sizeof(goalGlyphs) / sizeof(goalGlyphs[0]); i++)
    if (goalGlyphs[i].ch == ch) return &goalGlyphs[i];
  return nullptr;
}

// One word, centered inside the 16-column half starting at halfLeftX,
// 1 LED between letters. "GREEN" is 18 wide (even with the 2-wide 'e's),
// so a word wider than 16 is pushed back to end at the half's far edge:
// it then starts at col 14, which is empty on the green screen (the flag
// and one-digit scores stay within cols 4-11). Only "GREEN" does this,
// and it is only ever drawn on the right half.
static void drawGoalWord(const char *word, int16_t halfLeftX, int16_t top, uint16_t color)
{
  uint8_t n = strlen(word);
  int16_t total = 0;
  for (uint8_t i = 0; i < n; i++)
  {
    const SmallGlyph *g = findGoalGlyph(word[i]);
    if (g) total += g->w + (i < n - 1 ? 1 : 0);
  }
  int16_t x = (total > 16) ? (halfLeftX + 16 - total)
                           : (halfLeftX + (16 - total) / 2);

  for (uint8_t i = 0; i < n; i++)
  {
    const SmallGlyph *g = findGoalGlyph(word[i]);
    if (!g) continue;
    for (uint8_t r = 0; r < 6; r++)
      for (uint8_t col = 0; col < g->w; col++)
        if (g->rows[r][col] == '#') plotColorPixel(x + col, top + r, color);
    x += g->w + 1;
  }
}

static uint8_t        gc_team = 0;   // 0 = red, 1 = green
static uint8_t        gc_score = 0;
static float          gc_phase = 0;
static unsigned long  gc_lastFrame = 0;

static void goalCeleb_drawFrame()
{
  const bool green = (gc_team == 1);

  display.clearDisplay();

  // ---- Text half (opposite side from the flag) ----
  const int16_t textHalfX = green ? 16 : 0;
  drawGoalWord("GOAL",  textHalfX, 1,  COLOR_YELLOW);
  drawGoalWord("FOR",   textHalfX, 9,  COLOR_YELLOW);
  drawGoalWord(green ? "GReeN" : "RED", textHalfX, 17, COLOR_YELLOW);
  drawGoalWord("TEAM",  textHalfX, 25, COLOR_YELLOW);

  // ---- Flag half: same calls as drawScoreboard() uses for its left
  //      flag (green team) / right flag (red team) ----
  const int flagW = 11, flagH = 9;
  if (green)
  {
    plotColorRect(0, 1, 1, 31, COLOR_GREEN);
    drawWavyFlag(1, 1, flagW, flagH, /*attach_col=*/0, /*star_dx=*/5, /*star_dy=*/3,
                 gc_phase, COLOR_RED, COLOR_GREEN);
    drawScoreDigits(1, 15, gc_score, COLOR_RED);
  }
  else
  {
    drawWavyFlag(20, 1, flagW, flagH, /*attach_col=*/flagW - 1, /*star_dx=*/5, /*star_dy=*/3,
                 gc_phase, COLOR_RED, COLOR_GREEN);
    plotColorRect(31, 1, 1, 31, COLOR_GREEN);
    drawScoreDigits(17, 30, gc_score, COLOR_RED);
  }
}

void goalCelebBegin(uint8_t team, uint8_t score)
{
  gc_team = team;
  gc_score = score;
  gc_phase = 0;
  gc_lastFrame = millis();
  goalCeleb_drawFrame();
}

// Pass the team's current score every call so an edit made during the
// celebration shows up; the panel is only redrawn every ~150 ms.
void goalCelebUpdate(uint8_t score)
{
  gc_score = score;

  unsigned long now = millis();
  if (now - gc_lastFrame < 150) return;
  gc_lastFrame = now;
  gc_phase += 0.6;
  goalCeleb_drawFrame();
}
#ifndef DISPLAY_H
#define DISPLAY_H

#include <Arduino.h>

// Must be defined BEFORE PxMatrix.h is included anywhere in the build -
// two chained panels shift out more data per refresh than one, and
// without slowing the SPI clock down the panel shows exactly the kind
// of scrambled/flickering corruption seen in testing. This was in the
// original single-file two-panel sketch and got dropped when the
// project was split into these separate files - restoring it here.
#define PxMATRIX_SPI_FREQUENCY 10000000

#include <PxMatrix.h>

// =====================================================================
// PANEL CONFIGURATION - TWO-PANEL VERSION
// Pins/dimensions match the two-panel rig (64x16 physical, chained as
// one 32x16-per-panel strip, remapped through a 32x32 virtual canvas so
// content can be taller than either panel alone). See Display.cpp for
// the rotation/split mechanics - nothing here or in GameState/WebServer/
// AudioPlayer/the main .ino needs to know about that; this header's
// public API is UNCHANGED from the single-panel version on purpose.
// =====================================================================

#ifdef ESP8266
  #define P_LAT 16   // D0
  #define P_A   5    // D1
  #define P_B   4    // D2
  #define P_C   15   // D8 - used now (1/4 scan on a 32x16 panel, two chained)
  #define P_OE  2    // D4
  // Data/Clock (D7/D5) are driven by hardware SPI inside PxMatrix - not
  // named here, same as before.
#endif

#define MATRIX_WIDTH  64  // physical, both panels chained
#define MATRIX_HEIGHT 16  // physical

extern PxMATRIX display;

// Standard colors (same palette as before)
extern uint16_t COLOR_RED;
extern uint16_t COLOR_GREEN;
extern uint16_t COLOR_BLUE;
extern uint16_t COLOR_WHITE;
extern uint16_t COLOR_YELLOW;
extern uint16_t COLOR_CYAN;
extern uint16_t COLOR_MAGENTA;
extern uint16_t COLOR_BLACK;

// ---------------------------------------------------------------------
// Call once from setup()
// ---------------------------------------------------------------------
void displayInit();

// ---------------------------------------------------------------------
// Each animation below follows the same non-blocking pattern as before:
//   xxxBegin(...)  -> starts (or restarts) the animation
//   xxxUpdate()     -> call every loop() iteration; advances one frame
//                       if enough time has passed. Returns true on the
//                       exact frame the animation finishes, false otherwise.
//   xxxIsRunning()   -> true while the animation is still in progress
//
// None of these ever call delay() - required since main.ino also has to
// keep servicing the web server and audio every iteration.
// ---------------------------------------------------------------------

// ---- Non-blocking countdown timer (counts down N, N-1, ... 1) ----
void countdownBegin(uint8_t seconds);
bool countdownIsRunning();
bool countdownUpdate();

// NOTE: flagsBegin()/flagsUpdate()/flagsIsRunning() from the single-panel
// version are gone - the main .ino never called them (dead code), and
// drawScoreboard() below already has its own flag animation built in.

// ---- Non-blocking scrolling text ----
void scrollTextBegin(uint8_t yPos, unsigned long frameDelayMs, const String &text,
                      uint8_t r, uint8_t g, uint8_t b);
bool scrollTextIsRunning();
bool scrollTextUpdate();

// mm:ss timer readout
void drawMatchTimer(uint16_t elapsedSeconds);

// Scoreboard: our flag/pole design (unchanged colors/look), now driven by
// real scores - red team's score on the left digit(s), green team's on
// the right. scoreColor tints the SCORE DIGITS only (matching the
// waiting-loop blue vs. other states, per the original spec); the flags/
// poles/star keep their own fixed colors regardless of scoreColor.
void drawScoreboard(uint8_t redScore, uint8_t greenScore, uint16_t scoreColor);

// "REF" in green, sized to fill the panel.
void drawReferee();

// "WAITING / FOR / START" on three centered lines, drawn all at once
// (no scrolling), 1-LED-thick letters: WAITING red, FOR green, START
// yellow, inside a 1-LED red border. One-shot draw - the panel refresh ISR
// keeps it lit until something else draws.
void drawWaitingForStart();

// ---- Goal celebration (static layout, flag keeps waving) ----
// team: 0 = red, 1 = green (matches GameState.h's Team enum numbering).
// Green team: flag + new score on the left half, "GOAL FOR GREEN TEAM"
// (yellow, stacked) on the right. Red team: mirrored.
// Pass the scoring team's CURRENT score to both calls.
void goalCelebBegin(uint8_t team, uint8_t score);
void goalCelebUpdate(uint8_t score);

// Flashing final time - caller decides on/off by calling this only during
// the "on" half of the flash cycle, and calling display.clearDisplay()
// directly during the "off" half (unchanged from before).
void drawFullTimeFlash(uint16_t finalElapsedSeconds);

#endif

@b707
You're right. I understand that trying different wire combinations isn't the proper way to find the solution, but I didn't have another way to determine the correct configuration for this panel. I spent a lot of time watching videos and searching GitHub and other resources. But I only found one YouTube channel where someone did a lot of work with P10 panels. I'll include the link to his page. That's why I shared all the details, code, wiring information, and test results, hoping someone with more experience with these panel variations could help identify the correct wiring.

I think i need this file.

To make sure we correct it at the right level i need to see this function

display.drawPixel(physX, physY, colonColor);

It is called in several locations

And this is the declaration of the object, so i need to see that class.
My intention is to swap the colors around depending on where the pixel is in the matrix, and correct it either to RBG or GBR. I do hope we will be dealing with 24-bit color, that will simplify things.

It defined in PxMatrix library:

The theory of the quarter scan DMD panels operation:

Coordinate transformations for 4scan panels - SmartMatrix Community

I didn't see where it was included, but in that case the easiest solution is to modify this function

inline void PxMATRIX::drawPixelRGB565(int16_t x, int16_t y, uint16_t color) {
  uint8_t r = ((((color >> 11) & 0x1F) * 527) + 23) >> 6;
  uint8_t g = ((((color >> 5) & 0x3F) * 259) + 33) >> 6;
  uint8_t b = (((color & 0x1F) * 527) + 23) >> 6;
#ifdef PxMATRIX_DOUBLE_BUFFER
  fillMatrixBuffer(x, y, r, g, b, !_active_buffer);
#else
  fillMatrixBuffer(x, y, r, g, b, false);
#endif
}

which is called by drawPixel()

inline void PxMATRIX::drawPixel(int16_t x, int16_t y, uint16_t color) {
  drawPixelRGB565(x, y, color);
}

but first i suggest you change the color order to that of either the first or the second matrix using

inline void PxMATRIX::setColorOrder(color_orders color_order)
{
  _color_order=color_order;
}

the enum is this

enum color_orders {RRGGBB, RRBBGG, GGRRBB, GGBBRR, BBRRGG, BBGGRR};

calling

display.setColorOrder(RRBBGG);

right after display.init()

That should already fix the

Now to modify the library and keep a specific copy for your project you can either make a copy of the original, rename it, and include that. (my preference) or keep a local copy in the sketch folder and change the #include <...> to #include "..." of course there is no harm in first just fiddling with the library to see if you can get it to work properly, but if the library get updated you will lose your modifications and if you want to use it with different panels you will get into trouble.

anyway

inline void PxMATRIX::drawPixelRGB565(int16_t x, int16_t y, uint16_t color) {
  if (x < _width / 2) {
    uint8_t r = ((((color >> 11) & 0x1F) * 527) + 23) >> 6;
    uint8_t b = (((color & 0x1F) * 527) + 23) >> 6;
  }
  else {
    uint8_t b = ((((color >> 11) & 0x1F) * 527) + 23) >> 6;
    uint8_t r = (((color & 0x1F) * 527) + 23) >> 6;
  } 
  uint8_t g = ((((color >> 5) & 0x3F) * 259) + 33) >> 6;
#ifdef PxMATRIX_DOUBLE_BUFFER
  fillMatrixBuffer(x, y, r, g, b, !_active_buffer);
#else
  fillMatrixBuffer(x, y, r, g, b, false);
#endif
}

should swap the Red and Blue for the second panel i would say, but start with adding

display.setColorOrder(RRBBGG);

to the main sketch, and maybe fiddle with it to see if you can get either panel in the correct color order, and then see which colors need to be swapped over

About the 74LS245, would a 74xx145 be fine as well ? i guess you are only increasing the logic levels, the bi-directional thing isn't really required is it ?

I don't think there's any need to change anything in the library. First of all, the OP should follow all the recommendations for connecting the panels, and the problem will most likely simply go away .

Well yes there is that. Still assigning at least the proper color order for the first panel should make a difference, and is not changing anything in the library, simply using one of the member functions of the class.

The OP has claimed that any modification of the wiring stops the panels from working at all, therefore i provided a 'software fix' for the different order of the 2 panels (which results from the connections as far as i can tell)