#include "GameRenderer.h"
#include "../../renderer/Renderer_iface.h"

#include "SyncLineRenderer.h"
#include "../../gameplay/core/Game.h"
#include "../../gameplay/coop/CoopGame.h"
#include "../../app/Fireworks.h"
#include "../ui/Font.h"
#include "../../gameplay/effects/LineEffect.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <sstream>
#include <string>
#include <limits>
#include <random>
#include <vector>
#include "../../core/Settings.h"
#include "../../graphics/effects/Starfield3D.h"

namespace {
struct ImpactSpark {
    float x = 0.0f;
    float y = 0.0f;
    float vx = 0.0f;
    float vy = 0.0f;
    float lifeMs = 0.0f;
    float maxLifeMs = 0.0f;
    float size = 0.0f;
    SDL_Color color{255, 255, 255, 255};
};

struct Sparkle {
    float x = 0.0f;
    float y = 0.0f;
    float vx = 0.0f;
    float vy = 0.0f;
    float lifeMs = 0.0f;
    float maxLifeMs = 0.0f;
    float size = 0.0f;
    SDL_Color color{255, 255, 255, 255};
    float pulse = 0.0f;
};

struct ActivePieceSmoothState {
    uint64_t sequence = 0;
    float visualX = 0.0f;
    bool initialized = false;
};

ActivePieceSmoothState s_activePieceSmooth;

Starfield3D s_inGridStarfield;
bool s_starfieldInitialized = false;
std::vector<Sparkle> s_sparkles;
float s_sparkleSpawnAcc = 0.0f;

struct AsteroidBurst {
    float x;
    float y;
    float lifeMs;
    float maxLifeMs;
    float baseRadius;
    SDL_Color color;
    float spin;
};

std::vector<AsteroidBurst> s_asteroidBursts;

struct AsteroidShard {
    float x;
    float y;
    float vx;
    float vy;
    float lifeMs;
    float maxLifeMs;
    float size;
    SDL_Color color;
};

std::vector<AsteroidShard> s_asteroidShards;
}

struct TransportEffectState {
    bool active = false;
    Uint32 startTick = 0;
    float durationMs = 600.0f;
    Game::Piece piece;
    float startX = 0.0f; // pixel origin of piece local (0,0)
    float startY = 0.0f;
    float targetX = 0.0f;
    float targetY = 0.0f;
    float tileSize = 24.0f;
    // Next preview that should fade in after the transfer completes
    Game::Piece nextPiece;
    float nextPreviewX = 0.0f;
    float nextPreviewY = 0.0f;
};

static TransportEffectState s_transport;

static float smoothstep(float t) {
    t = std::clamp(t, 0.0f, 1.0f);
    return t * t * (3.0f - 2.0f * t);
}

void GameRenderer::startTransportEffect(const Game::Piece& piece, float startX, float startY, float targetX, float targetY, float tileSize, float durationSeconds) {
    s_transport.active = true;
    s_transport.startTick = SDL_GetTicks();
    s_transport.durationMs = std::max(8.0f, durationSeconds * 1000.0f);
    s_transport.piece = piece;
    s_transport.startX = startX;
    s_transport.startY = startY;
    s_transport.targetX = targetX;
    s_transport.targetY = targetY;
    s_transport.tileSize = tileSize;
}

void GameRenderer::startTransportEffectForGame(Game* game, SDL_Texture* blocksTex, float logicalW, float logicalH, float logicalScale, float winW, float winH, float durationSeconds) {
    if (!game) return;

    // Recompute layout exactly like renderPlayingState so coordinates match
    const float MIN_MARGIN = 40.0f;
    const float TOP_MARGIN = 60.0f;
    const float PANEL_WIDTH = 180.0f;
    const float PANEL_SPACING = 30.0f;
    const float NEXT_PIECE_HEIGHT = 120.0f;
    const float BOTTOM_MARGIN = 60.0f;

    float contentScale = logicalScale;
    float contentW = logicalW * contentScale;
    float contentH = logicalH * contentScale;
    float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
    float contentOffsetY = (winH - contentH) * 0.5f / contentScale;

    const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
    const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;
    const float maxBlockSizeW = availableWidth / Game::COLS;
    const float maxBlockSizeH = availableHeight / Game::ROWS;
    const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
    const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f));

    const float GRID_W = Game::COLS * finalBlockSize;
    const float GRID_H = Game::ROWS * finalBlockSize;
    const float totalContentHeight = NEXT_PIECE_HEIGHT + GRID_H;
    const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
    const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
    const float contentStartY = TOP_MARGIN + verticalCenterOffset;
    const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
    const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
    const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
    const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY;

    // Compute next panel placement (same as renderPlayingState)
    const float NEXT_PANEL_WIDTH = GRID_W - finalBlockSize * 2.0f;
    const float NEXT_PANEL_HEIGHT = finalBlockSize * 3.0f;
    const float NEXT_PANEL_X = gridX + finalBlockSize;
    // Move NEXT panel a bit higher so it visually separates from the grid
    const float NEXT_PANEL_Y = gridY - NEXT_PANEL_HEIGHT - 12.0f;

    // We'll animate the piece that is now current (the newly spawned piece)
    const Game::Piece piece = game->current();

    // Determine piece bounds in its 4x4 to center into preview area
    int minCx = 4, maxCx = -1, minCy = 4, maxCy = -1;
    for (int cy = 0; cy < 4; ++cy) for (int cx = 0; cx < 4; ++cx) if (Game::cellFilled(piece, cx, cy)) { minCx = std::min(minCx, cx); maxCx = std::max(maxCx, cx); minCy = std::min(minCy, cy); maxCy = std::max(maxCy, cy); }
    if (maxCx < minCx) { minCx = 0; maxCx = 0; }
    if (maxCy < minCy) { minCy = 0; maxCy = 0; }

    const float labelReserve = finalBlockSize * 0.9f;
    const float previewTop = NEXT_PANEL_Y + std::min(labelReserve, NEXT_PANEL_HEIGHT * 0.45f);
    const float previewBottom = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT - finalBlockSize * 0.25f;
    const float previewCenterY = (previewTop + previewBottom) * 0.5f;
    const float previewCenterX = std::round(NEXT_PANEL_X + NEXT_PANEL_WIDTH * 0.5f);

    const float pieceWidth = static_cast<float>(maxCx - minCx + 1) * finalBlockSize;
    const float pieceHeight = static_cast<float>(maxCy - minCy + 1) * finalBlockSize;
    float startX = previewCenterX - pieceWidth * 0.5f - static_cast<float>(minCx) * finalBlockSize;
    float startY = previewCenterY - pieceHeight * 0.5f - static_cast<float>(minCy) * finalBlockSize;
    // Snap to grid columns
    float gridOriginX = NEXT_PANEL_X - finalBlockSize;
    float rel = startX - gridOriginX;
    float nearestTile = std::round(rel / finalBlockSize);
    startX = gridOriginX + nearestTile * finalBlockSize;
    // Raise preview slightly to remove a thin line artifact under the panel
    startY = std::round(startY) - 5.0f;

    // Target is the current piece's grid position
    float targetX = gridX + piece.x * finalBlockSize;
    float targetY = gridY + piece.y * finalBlockSize;

    // Also compute where the new NEXT preview (game->next()) will be drawn so we can fade it in later
    const Game::Piece nextPiece = game->next();

    // Compute next preview placement (center within NEXT panel)
    int nMinCx = 4, nMaxCx = -1, nMinCy = 4, nMaxCy = -1;
    for (int cy = 0; cy < 4; ++cy) for (int cx = 0; cx < 4; ++cx) if (Game::cellFilled(nextPiece, cx, cy)) { nMinCx = std::min(nMinCx, cx); nMaxCx = std::max(nMaxCx, cx); nMinCy = std::min(nMinCy, cy); nMaxCy = std::max(nMaxCy, cy); }
    if (nMaxCx < nMinCx) { nMinCx = 0; nMaxCx = 0; }
    if (nMaxCy < nMinCy) { nMinCy = 0; nMaxCy = 0; }

    const float previewTop2 = NEXT_PANEL_Y + std::min(finalBlockSize * 0.9f, NEXT_PANEL_HEIGHT * 0.45f);
    const float previewBottom2 = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT - finalBlockSize * 0.25f;
    const float previewCenterY2 = (previewTop2 + previewBottom2) * 0.5f;
    const float previewCenterX2 = std::round(NEXT_PANEL_X + NEXT_PANEL_WIDTH * 0.5f);
    const float pieceWidth2 = static_cast<float>(nMaxCx - nMinCx + 1) * finalBlockSize;
    const float pieceHeight2 = static_cast<float>(nMaxCy - nMinCy + 1) * finalBlockSize;
    float nextPreviewX = previewCenterX2 - pieceWidth2 * 0.5f - static_cast<float>(nMinCx) * finalBlockSize;
    float nextPreviewY = previewCenterY2 - pieceHeight2 * 0.5f - static_cast<float>(nMinCy) * finalBlockSize;
    // Snap to grid columns
    float gridOriginX2 = NEXT_PANEL_X - finalBlockSize;
    float rel2 = nextPreviewX - gridOriginX2;
    float nearestTile2 = std::round(rel2 / finalBlockSize);
    nextPreviewX = gridOriginX2 + nearestTile2 * finalBlockSize;
    nextPreviewY = std::round(nextPreviewY) - 5.0f;

    // Initialize transport state to perform fades: preview fade-out -> grid fade-in -> next preview fade-in
    s_transport.active = true;
    s_transport.startTick = SDL_GetTicks();
    s_transport.durationMs = std::max(100.0f, durationSeconds * 1000.0f);
    s_transport.piece = piece;
    s_transport.startX = startX;
    s_transport.startY = startY;
    s_transport.targetX = targetX;
    s_transport.targetY = targetY;
    s_transport.tileSize = finalBlockSize;
    // Store next preview piece and its pixel origin so we can fade it in later
    s_transport.nextPiece = nextPiece;
    s_transport.nextPreviewX = nextPreviewX;
    s_transport.nextPreviewY = nextPreviewY;
}

bool GameRenderer::isTransportActive() {
    return s_transport.active;
}

// Draw the ongoing transport effect; called every frame from renderPlayingState
static void updateAndDrawTransport(SDL_Renderer* renderer, SDL_Texture* blocksTex) {
    if (!s_transport.active) return;
    Uint32 now = SDL_GetTicks();
    float elapsed = static_cast<float>(now - s_transport.startTick);
    float total = s_transport.durationMs;
    if (total <= 0.0f) total = 1.0f;
    // Simultaneous cross-fade: as the NEXT preview fades out, the piece fades into the grid
    // and the new NEXT preview fades in — all driven by the same normalized t in [0,1].
    float t = std::clamp(elapsed / total, 0.0f, 1.0f);
    Uint8 previewAlpha = static_cast<Uint8>(std::lround(255.0f * (1.0f - t)));
    Uint8 gridAlpha = static_cast<Uint8>(std::lround(255.0f * t));
    Uint8 nextAlpha = gridAlpha; // fade new NEXT preview in at same rate as grid

        // Create renderer wrapper
        auto rwrap = renderer::MakeSDLRenderer(renderer);
        // Draw preview fade-out
        if (previewAlpha > 0) {
            if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, previewAlpha);
            for (int cy = 0; cy < 4; ++cy) {
                for (int cx = 0; cx < 4; ++cx) {
                    if (!Game::cellFilled(s_transport.piece, cx, cy)) continue;
                    float px = s_transport.startX + static_cast<float>(cx) * s_transport.tileSize;
                    float py = s_transport.startY + static_cast<float>(cy) * s_transport.tileSize;
                    GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, s_transport.tileSize, s_transport.piece.type);
                }
            }
            if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
        }

    // Draw grid fade-in (same intensity as next preview fade-in)
    if (gridAlpha > 0) {
        if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, gridAlpha);
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!Game::cellFilled(s_transport.piece, cx, cy)) continue;
                float gx = s_transport.targetX + static_cast<float>(cx) * s_transport.tileSize;
                float gy = s_transport.targetY + static_cast<float>(cy) * s_transport.tileSize;
                GameRenderer::drawBlockTexturePublic(renderer, blocksTex, gx, gy, s_transport.tileSize, s_transport.piece.type);
            }
        }
        if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
    }

    // Draw new NEXT preview fade-in (simultaneous)
    if (nextAlpha > 0) {
        if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, nextAlpha);
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!Game::cellFilled(s_transport.nextPiece, cx, cy)) continue;
                float nx = s_transport.nextPreviewX + static_cast<float>(cx) * s_transport.tileSize;
                float ny = s_transport.nextPreviewY + static_cast<float>(cy) * s_transport.tileSize;
                GameRenderer::drawBlockTexturePublic(renderer, blocksTex, nx, ny, s_transport.tileSize, s_transport.nextPiece.type);
            }
        }
        if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);
    }

    if (t >= 1.0f) {
        s_transport.active = false;
    }
}

// Color constants (copied from main.cpp)
static const SDL_Color COLORS[] = {
    {0, 0, 0, 255},        // 0: BLACK (empty)
    {0, 255, 255, 255},    // 1: I-piece - cyan
    {255, 255, 0, 255},    // 2: O-piece - yellow
    {128, 0, 128, 255},    // 3: T-piece - purple
    {0, 255, 0, 255},      // 4: S-piece - green
    {255, 0, 0, 255},      // 5: Z-piece - red
    {0, 0, 255, 255},      // 6: J-piece - blue
    {255, 165, 0, 255}     // 7: L-piece - orange
};

void GameRenderer::drawRect(SDL_Renderer* renderer, float x, float y, float w, float h, SDL_Color c) {
    auto rwrap = renderer::MakeSDLRenderer(renderer);
    rwrap->setDrawColor(c);
    SDL_FRect fr{x, y, w, h};
    rwrap->fillRectF(&fr);
}

static void drawAsteroid(SDL_Renderer* renderer, SDL_Texture* asteroidTex, float x, float y, float size, const AsteroidCell& cell) {
    auto outlineGravity = [&](float inset, SDL_Color color) {
        auto rwrap = renderer::MakeSDLRenderer(renderer);
        rwrap->setDrawColor(color);
        SDL_FRect glow{ x + inset, y + inset, size - inset * 2.0f, size - inset * 2.0f };
        rwrap->drawRectF(&glow);
    };

    if (asteroidTex) {
        const float SPRITE_SIZE = 90.0f;
        int col = 0;
        switch (cell.type) {
            case AsteroidType::Normal: col = 0; break;
            case AsteroidType::Armored: col = 1; break;
            case AsteroidType::Falling: col = 2; break;
            case AsteroidType::Core: col = 3; break;
        }
        int row = std::clamp<int>(cell.visualState, 0, 2);
        auto rwrap = renderer::MakeSDLRenderer(renderer);
        SDL_FRect src{ col * SPRITE_SIZE, row * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE };
        SDL_FRect dst{ x, y, size, size };
        rwrap->renderTexture(asteroidTex, &src, &dst);

        if (cell.gravityEnabled) {
            outlineGravity(2.0f, SDL_Color{255, 230, 120, 180});
        }
        return;
    }

    // Fallback: draw a colored quad (previous implementation)
    SDL_Color base{};
    switch (cell.type) {
        case AsteroidType::Normal: base = SDL_Color{172, 138, 104, 255}; break;
        case AsteroidType::Armored: base = SDL_Color{130, 150, 176, 255}; break;
        case AsteroidType::Falling: base = SDL_Color{210, 120, 82, 255}; break;
        case AsteroidType::Core: base = SDL_Color{198, 78, 200, 255}; break;
    }
    float hpScale = std::clamp(static_cast<float>(cell.hitsRemaining) / 3.0f, 0.25f, 1.0f);
    SDL_Color fill{
        static_cast<Uint8>(base.r * hpScale + 40 * (1.0f - hpScale)),
        static_cast<Uint8>(base.g * hpScale + 40 * (1.0f - hpScale)),
        static_cast<Uint8>(base.b * hpScale + 40 * (1.0f - hpScale)),
        255
    };
    auto rwrap = renderer::MakeSDLRenderer(renderer);
    rwrap->setDrawColor(fill);
    SDL_FRect body{x, y, size - 1.0f, size - 1.0f};
    rwrap->fillRectF(&body);

    SDL_Color outline = base;
    outline.a = 220;
    SDL_FRect border{x + 1.0f, y + 1.0f, size - 2.0f, size - 2.0f};
    rwrap->setDrawColor(outline);
    rwrap->drawRectF(&border);
    if (cell.gravityEnabled) {
        outlineGravity(2.0f, SDL_Color{255, 230, 120, 180});
    }
}

void GameRenderer::drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) {
    if (!blocksTex || blockType < 0 || blockType >= PIECE_COUNT) {
        // Fallback to colored rectangle if texture isn't available
        SDL_Color color = (blockType >= 0 && blockType < PIECE_COUNT) ? COLORS[blockType + 1] : SDL_Color{128, 128, 128, 255};
        drawRect(renderer, x, y, size-1, size-1, color);
        return;
    }
    
    // JavaScript uses: sx = type * spriteSize, sy = 0, with 2px padding
    // Each sprite is 90px wide in the horizontal sprite sheet
    const int SPRITE_SIZE = 90;
    float srcX = blockType * SPRITE_SIZE + 2;  // Add 2px padding like JS
    float srcY = 2;                            // Add 2px padding from top like JS
    float srcW = SPRITE_SIZE - 4;              // Subtract 4px total padding like JS
    float srcH = SPRITE_SIZE - 4;              // Subtract 4px total padding like JS
    
    SDL_FRect srcRect = {srcX, srcY, srcW, srcH};
    SDL_FRect dstRect = {x, y, size, size};
    auto rwrap = renderer::MakeSDLRenderer(renderer);
    rwrap->renderTexture(blocksTex, &srcRect, &dstRect);
}

void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost, float pixelOffsetX, float pixelOffsetY) {
    if (piece.type >= PIECE_COUNT) return;
    
    for (int cy = 0; cy < 4; ++cy) {
        for (int cx = 0; cx < 4; ++cx) {
            if (Game::cellFilled(piece, cx, cy)) {
                float px = ox + (piece.x + cx) * tileSize + pixelOffsetX;
                float py = oy + (piece.y + cy) * tileSize + pixelOffsetY;
                
                if (isGhost) {
                    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
                    
                    // Draw ghost piece as barely visible gray outline
                    auto rwrap = renderer::MakeSDLRenderer(renderer);
                    // Draw ghost fill
                    SDL_Color ghostFill{180,180,180,20};
                    rwrap->setDrawColor(ghostFill);
                    SDL_FRect rect = {px + 2, py + 2, tileSize - 4, tileSize - 4};
                    rwrap->fillRectF(&rect);
                    // Draw thin gray border
                    SDL_Color ghostBorder{180,180,180,30};
                    rwrap->setDrawColor(ghostBorder);
                    SDL_FRect border = {px + 1, py + 1, tileSize - 2, tileSize - 2};
                    rwrap->drawRectF(&border);
                } else {
                    drawBlockTexture(renderer, blocksTex, px, py, tileSize, piece.type);
                }
            }
        }
    }
}

void GameRenderer::drawBlockTexturePublic(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType) {
    // Forward to the private helper
    drawBlockTexture(renderer, blocksTex, x, y, size, blockType);
}

void GameRenderer::drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize) {
    if (pieceType >= PIECE_COUNT) return;
    auto rwrap = renderer::MakeSDLRenderer(renderer);
    
    // Use the first rotation (index 0) for preview
    Game::Piece previewPiece;
    previewPiece.type = pieceType;
    previewPiece.rot = 0;
    previewPiece.x = 0;
    previewPiece.y = 0;
    
    // Determine occupied bounds within 4x4 and center inside the 4x4 preview area
    int minCx = 4, maxCx = -1, minCy = 4, maxCy = -1;
    for (int cy = 0; cy < 4; ++cy) {
        for (int cx = 0; cx < 4; ++cx) {
            if (Game::cellFilled(previewPiece, cx, cy)) {
                minCx = std::min(minCx, cx);
                maxCx = std::max(maxCx, cx);
                minCy = std::min(minCy, cy);
                maxCy = std::max(maxCy, cy);
            }
        }
    }
    if (maxCx < minCx) { minCx = 0; maxCx = 0; }
    if (maxCy < minCy) { minCy = 0; maxCy = 0; }

    float areaW = 4.0f * tileSize;
    float areaH = 4.0f * tileSize;
    float pieceW = static_cast<float>(maxCx - minCx + 1) * tileSize;
    float pieceH = static_cast<float>(maxCy - minCy + 1) * tileSize;
    float offsetX = (areaW - pieceW) * 0.5f - static_cast<float>(minCx) * tileSize;
    float offsetY = (areaH - pieceH) * 0.5f - static_cast<float>(minCy) * tileSize;
    offsetX = std::round(offsetX);
    offsetY = std::round(offsetY);
    
    // Use semi-transparent alpha for preview blocks
    Uint8 previewAlpha = 180;
    if (blocksTex) {
        rwrap->setTextureAlphaMod(blocksTex, previewAlpha);
    }
    
    for (int cy = 0; cy < 4; ++cy) {
        for (int cx = 0; cx < 4; ++cx) {
            if (Game::cellFilled(previewPiece, cx, cy)) {
                float px = x + offsetX + cx * tileSize;
                float py = y + offsetY + cy * tileSize;
                GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, tileSize, pieceType);
            }
        }
    }
    
    // Reset alpha
    if (blocksTex) {
        rwrap->setTextureAlphaMod(blocksTex, 255);
    }
}

void GameRenderer::renderNextPanel(
    SDL_Renderer* renderer,
    FontAtlas* pixelFont,
    SDL_Texture* blocksTex,
    SDL_Texture* nextPanelTex,
    const Game::Piece& nextPiece,
    float panelX,
    float panelY,
    float panelW,
    float panelH,
    float tileSize
) {
    if (!renderer || !pixelFont) {
        return;
    }

    auto rwrap = renderer::MakeSDLRenderer(renderer);

    const SDL_Color gridBorderColor{60, 80, 160, 255}; // matches main grid outline
    const SDL_Color bayColor{8, 12, 24, 235};
    const SDL_Color bayOutline{25, 62, 86, 220};
    const SDL_Color labelColor{255, 220, 0, 255};

    // If an external NEXT panel texture is provided, draw it scaled into
    // the panel rectangle and skip the custom background/frame drawing.
    if (nextPanelTex) {
        SDL_FRect dst{panelX, panelY, panelW, panelH};
        rwrap->renderTexture(nextPanelTex, nullptr, &dst);
        const float labelPad = tileSize * 0.25f;
        pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor);
    } else {
        SDL_FRect bayRect{panelX, panelY, panelW, panelH};
        rwrap->setDrawColor(bayColor);
        rwrap->fillRectF(&bayRect);

        SDL_FRect thinOutline{panelX - 1.0f, panelY - 1.0f, panelW + 2.0f, panelH + 2.0f};
        auto drawOutlineNoBottom = [&](const SDL_FRect& rect, SDL_Color color) {
            rwrap->setDrawColor(color);
            const float left = rect.x;
            const float top = rect.y;
            const float right = rect.x + rect.w;
            const float bottom = rect.y + rect.h;
            rwrap->renderLine(left, top, right, top);       // top edge
            rwrap->renderLine(left, top, left, bottom);     // left edge
            rwrap->renderLine(right, top, right, bottom);   // right edge
        };

        drawOutlineNoBottom(thinOutline, gridBorderColor);
        drawOutlineNoBottom(bayRect, bayOutline);

        const float labelPad = tileSize * 0.25f;
        pixelFont->draw(renderer, panelX + labelPad, panelY + labelPad * 0.5f, "NEXT", 0.9f, labelColor);
    }

    if (nextPiece.type >= PIECE_COUNT) {
        return;
    }

    // Determine the occupied bounds of the tetromino within its 4x4 local grid.
    int minCx = 4;
    int maxCx = -1;
    int minCy = 4;
    int maxCy = -1;
    for (int cy = 0; cy < 4; ++cy) {
        for (int cx = 0; cx < 4; ++cx) {
            if (!Game::cellFilled(nextPiece, cx, cy)) {
                continue;
            }
            minCx = std::min(minCx, cx);
            maxCx = std::max(maxCx, cx);
            minCy = std::min(minCy, cy);
            maxCy = std::max(maxCy, cy);
        }
    }

    if (maxCx < minCx || maxCy < minCy) {
        return;
    }

    // Reserve a little headroom for the NEXT label, then center the piece in screen-space.
    const float labelReserve = tileSize * 0.9f;
    const float previewTop = panelY + std::min(labelReserve, panelH * 0.45f);
    const float previewBottom = panelY + panelH - tileSize * 0.25f;
    const float previewCenterY = (previewTop + previewBottom) * 0.5f;
    const float previewCenterX = std::round(panelX + panelW * 0.5f);

    const float pieceWidth = static_cast<float>(maxCx - minCx + 1) * tileSize;
    const float pieceHeight = static_cast<float>(maxCy - minCy + 1) * tileSize;
    // Center piece so its local cells fall exactly on grid-aligned pixel columns
    float startX = previewCenterX - pieceWidth * 0.5f - static_cast<float>(minCx) * tileSize;
    float startY = previewCenterY - pieceHeight * 0.5f - static_cast<float>(minCy) * tileSize;
    // Snap horizontal position to the playfield's tile grid so preview cells align exactly
    // with the main grid columns. `panelX` was computed as `gridX + tileSize` in caller,
    // so derive grid origin as `panelX - tileSize`.
    float gridOriginX = panelX - tileSize;
    float rel = startX - gridOriginX;
    float nearestTile = std::round(rel / tileSize);
    startX = gridOriginX + nearestTile * tileSize;
    // Round Y to pixel to avoid subpixel artifacts and nudge upward slightly
    startY = std::round(startY) - 5.0f;

    // If a transfer fade is active, the preview cells will be drawn by the
    // transport effect (with fade). Skip drawing the normal preview in that case.
    if (!s_transport.active) {
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!Game::cellFilled(nextPiece, cx, cy)) {
                    continue;
                }
                const float px = startX + static_cast<float>(cx) * tileSize;
                const float py = startY + static_cast<float>(cy) * tileSize;
                GameRenderer::drawBlockTexturePublic(renderer, blocksTex, px, py, tileSize, nextPiece.type);
            }
        }
    }
}

void GameRenderer::renderPlayingState(
    SDL_Renderer* renderer,
    Game* game,
    FontAtlas* pixelFont,
    LineEffect* lineEffect,
    SDL_Texture* blocksTex,
    SDL_Texture* asteroidsTex,
    SDL_Texture* statisticsPanelTex,
    SDL_Texture* scorePanelTex,
    SDL_Texture* nextPanelTex,
    SDL_Texture* holdPanelTex,
    bool countdownActive,
    float logicalW,
    float logicalH,
    float logicalScale,
    float winW,
    float winH,
    bool challengeClearFxActive,
    const std::vector<int>* challengeClearFxOrder,
    double challengeClearFxElapsedMs,
    double challengeClearFxDurationMs,
    const std::string* challengeStoryText,
    float challengeStoryAlpha
) {
    if (!game || !pixelFont) return;

    static std::vector<ImpactSpark> s_impactSparks;
    static uint32_t s_lastImpactFxId = 0;
    static Uint32 s_lastImpactTick = SDL_GetTicks();
    static std::mt19937 s_impactRng{ std::random_device{}() };

    Uint32 nowTicks = SDL_GetTicks();
    float sparkDeltaMs = static_cast<float>(nowTicks - s_lastImpactTick);
    s_lastImpactTick = nowTicks;
    if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) {
        sparkDeltaMs = 16.0f;
    }
    
    // Calculate actual content area (centered within the window)
    float contentScale = logicalScale;
    float contentW = logicalW * contentScale;
    float contentH = logicalH * contentScale;
    float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
    float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
    
    // Renderer wrapper and helper lambda for drawing rectangles with content offset
    auto rwrap = renderer::MakeSDLRenderer(renderer);
    auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
        rwrap->setDrawColor(c);
        SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
        rwrap->fillRectF(&fr);
    };

        // Responsive layout that scales with window size while maintaining margins
        const float MIN_MARGIN = 40.0f;
        const float TOP_MARGIN = 60.0f;
        const float PANEL_WIDTH = 180.0f;
        const float PANEL_SPACING = 30.0f;
        const float NEXT_PIECE_HEIGHT = 120.0f;
        const float BOTTOM_MARGIN = 60.0f;

        // Calculate layout dimensions
        const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
        const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PIECE_HEIGHT;

        const float maxBlockSizeW = availableWidth / Game::COLS;
        const float maxBlockSizeH = availableHeight / Game::ROWS;
        const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
        const float finalBlockSize = std::max(20.0f, std::min(BLOCK_SIZE, 40.0f));

        const float GRID_W = Game::COLS * finalBlockSize;
        const float GRID_H = Game::ROWS * finalBlockSize;

        // Calculate positions
        const float totalContentHeight = NEXT_PIECE_HEIGHT + GRID_H;
        const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
        const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
        const float contentStartY = TOP_MARGIN + verticalCenterOffset;

        const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
        const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;

        const float statsX = layoutStartX + contentOffsetX;
        const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
        const float gridY = contentStartY + NEXT_PIECE_HEIGHT + contentOffsetY;

        const float statsY = gridY;
        const float statsW = PANEL_WIDTH;
        const float statsH = GRID_H;

        // Next piece preview position
        // Make NEXT panel span the inner area of the grid with a 1-cell margin on both sides
        const float NEXT_PANEL_WIDTH = GRID_W - finalBlockSize * 2.0f; // leave 1 cell on left and right
        const float NEXT_PANEL_HEIGHT = finalBlockSize * 3.0f;
        const float NEXT_PANEL_X = gridX + finalBlockSize; // align panel so there's exactly one cell margin
        // Move NEXT panel a bit higher so it visually separates from the grid
        const float NEXT_PANEL_Y = gridY - NEXT_PANEL_HEIGHT - 12.0f; // nudge up ~12px
    
    // Handle line clearing effects
    if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
        auto completedLines = game->getCompletedLines();
        lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
        // Trigger fireworks visually for a 4-line clear (TETRIS)
        if (completedLines.size() == 4) {
            // spawn near center of grid
            AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
        }
    }
    
    // Draw game grid border
    drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
    // Draw a 1px blue border around grid but omit the top horizontal so the NEXT panel
    // can visually join seamlessly. We'll draw left, right and bottom bands manually.
    {
        SDL_Color blue{60, 80, 160, 255};
        // left vertical band (1px wide)
        drawRectWithOffset(gridX - 1 - contentOffsetX, gridY - contentOffsetY, 1.0f, GRID_H, blue);
        // right vertical band (1px wide)
        drawRectWithOffset(gridX + GRID_W - contentOffsetX, gridY - contentOffsetY, 1.0f, GRID_H, blue);
        // bottom horizontal band (1px high)
        drawRectWithOffset(gridX - 1 - contentOffsetX, gridY + GRID_H - contentOffsetY, GRID_W + 2.0f, 1.0f, blue);
    }
    drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
    
    // Draw stats panel backdrop using the same art as the score panel
    const float blocksPanelPadLeft = 34.0f;
    const float blocksPanelPadRight = 10.0f;
    const float blocksPanelPadY = 26.0f;
    SDL_FRect blocksPanelBg{
        statsX - blocksPanelPadLeft,
        gridY - blocksPanelPadY,
        statsW + blocksPanelPadLeft + blocksPanelPadRight,
        GRID_H + blocksPanelPadY * 2.0f
    };
    if (statisticsPanelTex) {
        // Use the dedicated statistics panel image; scale it to fit the panel while
        // preserving aspect ratio (no crop) so very tall source images don't overflow.
        float texWf = 0.0f, texHf = 0.0f;
        if (SDL_GetTextureSize(statisticsPanelTex, &texWf, &texHf)) {
            if (texWf > 0.0f && texHf > 0.0f) {
            const float destW = blocksPanelBg.w;
            const float destH = blocksPanelBg.h;
            const float scale = std::min(destW / texWf, destH / texHf);
            const float scaledW = texWf * scale;
            const float scaledH = texHf * scale;
            // Center the scaled texture inside the panel
            SDL_FRect dstF{
                blocksPanelBg.x + (destW - scaledW) * 0.5f,
                blocksPanelBg.y + (destH - scaledH) * 0.5f,
                scaledW,
                scaledH
            };
            rwrap->renderTexture(statisticsPanelTex, nullptr, &dstF);
            }
        } else {
            // Fallback: render entire texture stretched to panel
            rwrap->renderTexture(statisticsPanelTex, nullptr, &blocksPanelBg);
        }
    } else if (scorePanelTex) {
        rwrap->renderTexture(scorePanelTex, nullptr, &blocksPanelBg);
    } else {
        rwrap->setDrawColor(SDL_Color{12, 18, 32, 205});
        rwrap->fillRectF(&blocksPanelBg);
    }
    
    // Draw grid lines
    rwrap->setDrawColor(SDL_Color{40, 45, 60, 255});
    for (int x = 1; x < Game::COLS; ++x) {
        float lineX = gridX + x * finalBlockSize;
        rwrap->renderLine(lineX, gridY, lineX, gridY + GRID_H);
    }
    for (int y = 1; y < Game::ROWS; ++y) {
        float lineY = gridY + y * finalBlockSize;
        rwrap->renderLine(gridX, lineY, gridX + GRID_W, lineY);
    }

    if (!s_starfieldInitialized) {
        s_inGridStarfield.init(static_cast<int>(GRID_W), static_cast<int>(GRID_H), 180);
        s_starfieldInitialized = true;
    } else {
        s_inGridStarfield.resize(static_cast<int>(GRID_W), static_cast<int>(GRID_H));
    }

    const float deltaSeconds = std::clamp(static_cast<float>(sparkDeltaMs) / 1000.0f, 0.0f, 0.033f);
    s_inGridStarfield.update(deltaSeconds);

    bool appliedMagnet = false;
    if (game) {
        const Game::Piece& activePiece = game->current();
        const int pieceType = static_cast<int>(activePiece.type);
        if (pieceType >= 0 && pieceType < PIECE_COUNT) {
            float sumLocalX = 0.0f;
            float sumLocalY = 0.0f;
            int filledCells = 0;
            for (int cy = 0; cy < 4; ++cy) {
                for (int cx = 0; cx < 4; ++cx) {
                    if (!Game::cellFilled(activePiece, cx, cy)) {
                        continue;
                    }
                    sumLocalX += (activePiece.x + cx + 0.5f) * finalBlockSize;
                    sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize;
                    ++filledCells;
                }
            }
            if (filledCells > 0) {
                float magnetLocalX = sumLocalX / static_cast<float>(filledCells);
                float magnetLocalY = sumLocalY / static_cast<float>(filledCells);
                magnetLocalX = std::clamp(magnetLocalX, 0.0f, GRID_W);
                magnetLocalY = std::clamp(magnetLocalY, 0.0f, GRID_H);
                const float magnetStrength = finalBlockSize * 2.2f;
                s_inGridStarfield.setMagnetTarget(magnetLocalX, magnetLocalY, magnetStrength);
                appliedMagnet = true;
            }
        }
    }
    if (!appliedMagnet) {
        s_inGridStarfield.clearMagnetTarget();
    }

    SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE;
    SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
    // rwrap already declared near function start; reuse it here.
    // Add a small, smooth sub-pixel jitter to the starfield origin so the
    // brightest star doesn't permanently sit exactly at the visual center.
    {
        const float jitterAmp = 1.6f; // max pixels of jitter
        const uint32_t now = SDL_GetTicks();
        const float tms = static_cast<float>(now) * 0.001f;
        const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f;
        const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f);
        s_inGridStarfield.draw(renderer, gridX + jitterX, gridY + jitterY, 0.22f, true);
    }

    // Update and spawn ambient sparkles inside/around the grid
    // Use the same RNG and timing values used for impact sparks
    if (!game->isPaused()) {
        // Spawn rate: ~10 sparks/sec total (adjustable)
        const float spawnInterval = 0.08f; // seconds
        s_sparkleSpawnAcc += deltaSeconds;
        while (s_sparkleSpawnAcc >= spawnInterval) {
            s_sparkleSpawnAcc -= spawnInterval;

            Sparkle s;
            // Choose spawn area: near active piece magnet if present, otherwise along top/border
            bool spawnNearPiece = appliedMagnet && (std::uniform_real_distribution<float>(0.0f,1.0f)(s_impactRng) > 0.35f);
            float sx = 0.0f, sy = 0.0f;
            if (spawnNearPiece) {
                // Use starfield magnet target if set (approx center of active piece)
                // Random jitter around magnet
                float jitterX = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_impactRng);
                float jitterY = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_impactRng);
                // s_inGridStarfield stores magnet in local coords when used; approximate from magnet calculations above
                // We'll center near grid center if magnet not available
                sx = std::clamp(GRID_W * 0.5f + jitterX, -finalBlockSize * 2.0f, GRID_W + finalBlockSize * 2.0f);
                sy = std::clamp(GRID_H * 0.4f + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f);
            } else {
                // Spawn along border: choose side and position
                float side = std::uniform_real_distribution<float>(0.0f, 1.0f)(s_impactRng);
                // Border band width (how far outside the grid sparks can appear)
                const float borderBand = std::max(12.0f, finalBlockSize * 1.0f);
                if (side < 0.2f) { // left (outside)
                    sx = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_impactRng);
                    sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_impactRng);
                } else if (side < 0.4f) { // right (outside)
                    sx = std::uniform_real_distribution<float>(GRID_W, GRID_W + borderBand)(s_impactRng);
                    sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_impactRng);
                } else if (side < 0.6f) { // top (outside)
                    sx = std::uniform_real_distribution<float>(-borderBand, GRID_W + borderBand)(s_impactRng);
                    sy = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_impactRng);
                } else if (side < 0.9f) { // top/inside border area
                    sx = std::uniform_real_distribution<float>(0.0f, GRID_W)(s_impactRng);
                    sy = std::uniform_real_distribution<float>(0.0f, finalBlockSize * 2.0f)(s_impactRng);
                } else { // bottom (outside)
                    sx = std::uniform_real_distribution<float>(-borderBand, GRID_W + borderBand)(s_impactRng);
                    sy = std::uniform_real_distribution<float>(GRID_H, GRID_H + borderBand)(s_impactRng);
                }
            }

            s.x = sx;
            s.y = sy;
            float speed = std::uniform_real_distribution<float>(10.0f, 60.0f)(s_impactRng);
            float ang = std::uniform_real_distribution<float>(-3.14159f, 3.14159f)(s_impactRng);
            s.vx = std::cos(ang) * speed;
            s.vy = std::sin(ang) * speed * 0.25f; // slower vertical movement
            s.maxLifeMs = std::uniform_real_distribution<float>(350.0f, 900.0f)(s_impactRng);
            s.lifeMs = s.maxLifeMs;
            s.size = std::uniform_real_distribution<float>(1.5f, 5.0f)(s_impactRng);
            // Soft color range towards warm/cyan tints
            if (std::uniform_real_distribution<float>(0.0f,1.0f)(s_impactRng) < 0.5f) {
                s.color = SDL_Color{255, 230, 180, 255};
            } else {
                s.color = SDL_Color{180, 220, 255, 255};
            }
            s.pulse = std::uniform_real_distribution<float>(0.0f, 6.28f)(s_impactRng);

            s_sparkles.push_back(s);
        }
    }

    // Update and draw sparkles
    if (!s_sparkles.empty()) {
        auto it = s_sparkles.begin();
        while (it != s_sparkles.end()) {
            Sparkle &sp = *it;
            sp.lifeMs -= sparkDeltaMs;
            if (sp.lifeMs <= 0.0f) {
                // On expiration, spawn a small burst of ImpactSparks (smaller boxes)
                const int burstCount = std::uniform_int_distribution<int>(4, 8)(s_impactRng);
                for (int bi = 0; bi < burstCount; ++bi) {
                    ImpactSpark ps;
                    // Position in absolute coords (same space as other impact sparks)
                    ps.x = gridX + sp.x + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_impactRng);
                    ps.y = gridY + sp.y + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_impactRng);
                    float ang = std::uniform_real_distribution<float>(0.0f, 6.2831853f)(s_impactRng);
                    float speed = std::uniform_real_distribution<float>(10.0f, 120.0f)(s_impactRng);
                    ps.vx = std::cos(ang) * speed;
                    ps.vy = std::sin(ang) * speed * 0.8f;
                    ps.maxLifeMs = std::uniform_real_distribution<float>(220.0f, 500.0f)(s_impactRng);
                    ps.lifeMs = ps.maxLifeMs;
                    ps.size = std::max(1.0f, sp.size * 0.5f);
                    ps.color = sp.color;
                    s_impactSparks.push_back(ps);
                }

                it = s_sparkles.erase(it);
                continue;
            }
            float lifeRatio = sp.lifeMs / sp.maxLifeMs;
            // simple motion
            sp.x += sp.vx * deltaSeconds;
            sp.y += sp.vy * deltaSeconds;
            sp.vy *= 0.995f; // slight damping
            sp.pulse += deltaSeconds * 8.0f;

            // Fade and pulse alpha
            float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
            Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
            SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
            rwrap->setDrawColor(SDL_Color{sp.color.r, sp.color.g, sp.color.b, alpha});
            float half = sp.size * 0.5f;
            SDL_FRect fr{gridX + sp.x - half, gridY + sp.y - half, sp.size, sp.size};
            rwrap->fillRectF(&fr);

            ++it;
        }
    }

    SDL_SetRenderDrawBlendMode(renderer, oldBlend);

    renderNextPanel(renderer, pixelFont, blocksTex, nextPanelTex, game->next(), NEXT_PANEL_X, NEXT_PANEL_Y, NEXT_PANEL_WIDTH, NEXT_PANEL_HEIGHT, finalBlockSize);

    // Draw a small filled connector to visually merge NEXT panel and grid border
    // If an external NEXT panel texture is used, skip the connector to avoid
    // drawing a visible seam under the image/artwork.
        if (!nextPanelTex) {
        rwrap->setDrawColor(SDL_Color{60, 80, 160, 255}); // same as grid border
        float connectorY = NEXT_PANEL_Y + NEXT_PANEL_HEIGHT; // bottom of next panel (near grid top)
        SDL_FRect connRect{ NEXT_PANEL_X, connectorY - 1.0f, NEXT_PANEL_WIDTH, 2.0f };
        rwrap->fillRectF(&connRect);
    }

    // Draw transport effect if active (renders the moving piece and trail)
    updateAndDrawTransport(renderer, blocksTex);
    
    // Precompute row drop offsets (line collapse effect)
    std::array<float, Game::ROWS> rowDropOffsets{};
    for (int y = 0; y < Game::ROWS; ++y) {
        rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
    }

    // Spawn glamour bursts for freshly destroyed asteroids
    if (game) {
        const auto& bursts = game->getRecentAsteroidExplosions();
        if (!bursts.empty()) {
            std::uniform_real_distribution<float> lifeDist(280.0f, 420.0f);
            std::uniform_real_distribution<float> radiusDist(finalBlockSize * 0.35f, finalBlockSize * 0.7f);
            std::uniform_real_distribution<float> spinDist(-4.0f, 4.0f);
            std::uniform_real_distribution<float> shardLife(240.0f, 520.0f);
            std::uniform_real_distribution<float> shardVX(-0.16f, 0.16f);
            std::uniform_real_distribution<float> shardVY(-0.22f, -0.06f);
            std::uniform_real_distribution<float> shardSize(finalBlockSize * 0.06f, finalBlockSize * 0.12f);
            for (const auto& p : bursts) {
                if (p.x < 0 || p.x >= Game::COLS || p.y < 0 || p.y >= Game::ROWS) {
                    continue;
                }
                float fx = gridX + (static_cast<float>(p.x) + 0.5f) * finalBlockSize;
                float fy = gridY + (static_cast<float>(p.y) + 0.5f) * finalBlockSize + rowDropOffsets[p.y];

                SDL_Color palette[3] = {
                    SDL_Color{255, 230, 120, 255},
                    SDL_Color{140, 220, 255, 255},
                    SDL_Color{255, 160, 235, 255}
                };
                SDL_Color c = palette[s_impactRng() % 3];
                AsteroidBurst burst{
                    fx,
                    fy,
                    lifeDist(s_impactRng),
                    0.0f,
                    radiusDist(s_impactRng),
                    c,
                    spinDist(s_impactRng)
                };
                burst.maxLifeMs = burst.lifeMs;
                s_asteroidBursts.push_back(burst);

                // Spawn shards for extra sparkle
                int shardCount = 10 + (s_impactRng() % 8);
                for (int i = 0; i < shardCount; ++i) {
                    AsteroidShard shard{
                        fx,
                        fy,
                        shardVX(s_impactRng),
                        shardVY(s_impactRng),
                        shardLife(s_impactRng),
                        0.0f,
                        shardSize(s_impactRng),
                        c
                    };
                    shard.maxLifeMs = shard.lifeMs;
                    s_asteroidShards.push_back(shard);
                }
            }
            game->clearRecentAsteroidExplosions();
        }
    }

    // Draw the game board
    const auto &board = game->boardRef();
    const auto &asteroidCells = game->asteroidCells();
    const bool challengeMode = game->getMode() == GameMode::Challenge;
    float impactStrength = 0.0f;
    float impactEased = 0.0f;
    std::array<uint8_t, Game::COLS * Game::ROWS> impactMask{};
    std::array<float, Game::COLS * Game::ROWS> impactWeight{};
    if (game->hasHardDropShake()) {
        impactStrength = static_cast<float>(game->hardDropShakeStrength());
        impactStrength = std::clamp(impactStrength, 0.0f, 1.0f);
        impactEased = impactStrength * impactStrength;
        const auto& impactCells = game->getHardDropCells();
        for (const auto& cell : impactCells) {
            if (cell.x < 0 || cell.x >= Game::COLS || cell.y < 0 || cell.y >= Game::ROWS) {
                continue;
            }
            int idx = cell.y * Game::COLS + cell.x;
            impactMask[idx] = 1;
            impactWeight[idx] = 1.0f;

            int depth = 0;
            for (int ny = cell.y + 1; ny < Game::ROWS && depth < 4; ++ny) {
                if (board[ny * Game::COLS + cell.x] == 0) {
                    break;
                }
                ++depth;
                int nidx = ny * Game::COLS + cell.x;
                impactMask[nidx] = 1;
                float weight = std::max(0.15f, 1.0f - depth * 0.35f);
                impactWeight[nidx] = std::max(impactWeight[nidx], weight);
            }
        }
    }

    bool shouldSpawnCrackles = game->hasHardDropShake() && !game->getHardDropCells().empty() && game->getHardDropFxId() != s_lastImpactFxId;
    if (shouldSpawnCrackles) {
        s_lastImpactFxId = game->getHardDropFxId();
        std::uniform_real_distribution<float> jitter(-finalBlockSize * 0.2f, finalBlockSize * 0.2f);
        std::uniform_real_distribution<float> velX(-0.04f, 0.04f);
        std::uniform_real_distribution<float> velY(0.035f, 0.07f);
        std::uniform_real_distribution<float> lifespan(210.0f, 320.0f);
        std::uniform_real_distribution<float> sizeDist(finalBlockSize * 0.08f, finalBlockSize * 0.14f);

        const auto& impactCells = game->getHardDropCells();
        for (const auto& cell : impactCells) {
            if (cell.x < 0 || cell.x >= Game::COLS || cell.y < 0 || cell.y >= Game::ROWS) {
                continue;
            }
            int idx = cell.y * Game::COLS + cell.x;
            int v = (cell.y >= 0) ? board[idx] : 0;
            SDL_Color baseColor = (v > 0 && v < PIECE_COUNT + 1) ? COLORS[v] : SDL_Color{255, 220, 180, 255};
            float cellX = gridX + (cell.x + 0.5f) * finalBlockSize;
            float cellY = gridY + (cell.y + 0.85f) * finalBlockSize + rowDropOffsets[cell.y];
            for (int i = 0; i < 4; ++i) {
                ImpactSpark spark;
                spark.x = cellX + jitter(s_impactRng);
                spark.y = cellY + jitter(s_impactRng) * 0.25f;
                spark.vx = velX(s_impactRng);
                spark.vy = velY(s_impactRng);
                spark.lifeMs = lifespan(s_impactRng);
                spark.maxLifeMs = spark.lifeMs;
                spark.size = sizeDist(s_impactRng);
                spark.color = SDL_Color{
                    static_cast<Uint8>(std::min(255, baseColor.r + 30)),
                    static_cast<Uint8>(std::min(255, baseColor.g + 30)),
                    static_cast<Uint8>(std::min(255, baseColor.b + 30)),
                    255
                };
                s_impactSparks.push_back(spark);
            }
        }
    }

    std::array<float, Game::COLS * Game::ROWS> challengeClearMask{};
    const bool challengeClearActive = challengeClearFxActive && challengeClearFxOrder && !challengeClearFxOrder->empty() && challengeClearFxDurationMs > 0.0;
    if (challengeClearActive) {
        const double totalDuration = std::max(50.0, challengeClearFxDurationMs);
        const double perCell = totalDuration / static_cast<double>(challengeClearFxOrder->size());
        for (size_t i = 0; i < challengeClearFxOrder->size(); ++i) {
            int idx = (*challengeClearFxOrder)[i];
            if (idx < 0 || idx >= static_cast<int>(challengeClearMask.size())) {
                continue;
            }
            double startMs = perCell * static_cast<double>(i);
            double local = (challengeClearFxElapsedMs - startMs) / perCell;
            float progress = static_cast<float>(std::clamp(local, 0.0, 1.0));
            if (progress > 0.0f) {
                challengeClearMask[idx] = progress;
            }
        }
    }

    for (int y = 0; y < Game::ROWS; ++y) {
        float dropOffset = rowDropOffsets[y];
        for (int x = 0; x < Game::COLS; ++x) {
            int v = board[y * Game::COLS + x];
            if (v > 0) {
                float bx = gridX + x * finalBlockSize;
                float by = gridY + y * finalBlockSize + dropOffset;
                const int cellIdx = y * Game::COLS + x;
                float weight = impactWeight[cellIdx];
                if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) {
                    float cellSeed = static_cast<float>((x * 37 + y * 61) % 113);
                    float t = static_cast<float>(nowTicks % 10000) * 0.018f + cellSeed;
                    float amplitude = 6.0f * impactEased * weight;
                    float freq = 2.0f + weight * 1.3f;
                    bx += amplitude * std::sin(t * freq);
                    by += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
                }

                float clearProgress = challengeClearMask[cellIdx];
                float clearAlpha = 1.0f;
                float clearScale = 1.0f;
                if (clearProgress > 0.0f) {
                    float eased = smoothstep(clearProgress);
                    clearAlpha = std::max(0.0f, 1.0f - eased);
                    clearScale = 1.0f + 0.35f * eased;
                    float offset = (finalBlockSize - finalBlockSize * clearScale) * 0.5f;
                    bx += offset;
                    by += offset;
                    float jitter = eased * 2.0f;
                    bx += std::sin(static_cast<float>(cellIdx) * 3.1f) * jitter;
                    by += std::cos(static_cast<float>(cellIdx) * 2.3f) * jitter * 0.6f;
                }

                bool isAsteroid = challengeMode && asteroidCells[cellIdx].has_value();
                if (isAsteroid) {
                    const AsteroidCell& cell = *asteroidCells[cellIdx];
                    float spawnScale = 1.0f;
                    float spawnAlpha = 1.0f;
                    if (countdownActive) {
                        // Staggered pop-in while counting: start oversized, fade to 1.0 with ease
                        const float t = static_cast<float>(SDL_GetTicks() & 2047) * 0.0015f; // ~0..3s loop
                        float phase = std::fmod(t + (float(cellIdx % 11) * 0.12f), 1.6f);
                        float pulse = std::clamp(phase / 1.2f, 0.0f, 1.0f);
                        float eased = smoothstep(pulse);
                        spawnScale = 1.35f - 0.35f * eased; // big -> normal
                        spawnAlpha = 0.25f + 0.75f * eased; // fade in
                    }

                    if (asteroidsTex && spawnAlpha < 1.0f) {
                        rwrap->setTextureAlphaMod(asteroidsTex, static_cast<Uint8>(std::clamp(spawnAlpha, 0.0f, 1.0f) * 255.0f));
                    }

                    float size = finalBlockSize * spawnScale * clearScale;
                    float offset = (finalBlockSize - size) * 0.5f;
                    if (asteroidsTex && clearAlpha < 1.0f) {
                        Uint8 alpha = static_cast<Uint8>(std::clamp(spawnAlpha * clearAlpha, 0.0f, 1.0f) * 255.0f);
                        rwrap->setTextureAlphaMod(asteroidsTex, alpha);
                    }
                    drawAsteroid(renderer, asteroidsTex, bx + offset, by + offset, size, cell);

                    if (asteroidsTex && (spawnAlpha < 1.0f || clearAlpha < 1.0f)) {
                        rwrap->setTextureAlphaMod(asteroidsTex, 255);
                    }
                } else {
                    if (blocksTex && clearAlpha < 1.0f) {
                        rwrap->setTextureAlphaMod(blocksTex, static_cast<Uint8>(std::clamp(clearAlpha, 0.0f, 1.0f) * 255.0f));
                    }
                    drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize * clearScale, v - 1);
                    if (blocksTex && clearAlpha < 1.0f) {
                        rwrap->setTextureAlphaMod(blocksTex, 255);
                    }
                }
            }
        }
    }

    // Update & draw asteroid glamour shards and bursts
    if (!s_asteroidShards.empty() || !s_asteroidBursts.empty()) {
        SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);

        // Shards
        auto shardIt = s_asteroidShards.begin();
        while (shardIt != s_asteroidShards.end()) {
            AsteroidShard& s = *shardIt;
            s.lifeMs -= sparkDeltaMs;
            if (s.lifeMs <= 0.0f) {
                shardIt = s_asteroidShards.erase(shardIt);
                continue;
            }
            s.vy += 0.0007f * sparkDeltaMs;
            s.x += s.vx * sparkDeltaMs;
            s.y += s.vy * sparkDeltaMs;
            float lifeRatio = std::clamp(static_cast<float>(s.lifeMs / s.maxLifeMs), 0.0f, 1.0f);
            Uint8 alpha = static_cast<Uint8>(lifeRatio * 200.0f);
            rwrap->setDrawColor(SDL_Color{s.color.r, s.color.g, s.color.b, alpha});
            float size = s.size * (0.7f + (1.0f - lifeRatio) * 0.8f);
            SDL_FRect shardRect{
                s.x - size * 0.5f,
                s.y - size * 0.5f,
                size,
                size * 1.4f
            };
            rwrap->fillRectF(&shardRect);
            ++shardIt;
        }

        // Bursts
        auto it = s_asteroidBursts.begin();
        while (it != s_asteroidBursts.end()) {
            AsteroidBurst& b = *it;
            b.lifeMs -= sparkDeltaMs;
            if (b.lifeMs <= 0.0f) {
                it = s_asteroidBursts.erase(it);
                continue;
            }
            float t = 1.0f - static_cast<float>(b.lifeMs / b.maxLifeMs);
            float alpha = std::clamp(1.0f - t, 0.0f, 1.0f);
            float radius = b.baseRadius * (1.0f + t * 1.6f);
            float thickness = std::max(2.0f, radius * 0.25f);
            float jitter = std::sin(t * 12.0f + b.spin) * 2.0f;

            SDL_Color c = b.color;
            Uint8 a = static_cast<Uint8>(alpha * 220.0f);
            rwrap->setDrawColor(SDL_Color{c.r, c.g, c.b, a});
            SDL_FRect outer{
                b.x - radius + jitter,
                b.y - radius + jitter,
                radius * 2.0f,
                radius * 2.0f
            };
            rwrap->drawRectF(&outer);

            SDL_FRect inner{
                b.x - (radius - thickness),
                b.y - (radius - thickness),
                (radius - thickness) * 2.0f,
                (radius - thickness) * 2.0f
            };
            rwrap->setDrawColor(SDL_Color{255, 255, 255, static_cast<Uint8>(a * 0.9f)});
            rwrap->drawRectF(&inner);
            ++it;
        }
        SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
    }

    if (!s_impactSparks.empty()) {
        SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
        auto it = s_impactSparks.begin();
        while (it != s_impactSparks.end()) {
            ImpactSpark& spark = *it;
            spark.vy += 0.00045f * sparkDeltaMs;
            spark.x += spark.vx * sparkDeltaMs;
            spark.y += spark.vy * sparkDeltaMs;
            spark.lifeMs -= sparkDeltaMs;
            if (spark.lifeMs <= 0.0f) {
                it = s_impactSparks.erase(it);
                continue;
            }
            float lifeRatio = spark.lifeMs / spark.maxLifeMs;
            Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
            rwrap->setDrawColor(SDL_Color{spark.color.r, spark.color.g, spark.color.b, alpha});
            SDL_FRect sparkRect{
                spark.x - spark.size * 0.5f,
                spark.y - spark.size * 0.5f,
                spark.size,
                spark.size * 1.4f
            };
            rwrap->fillRectF(&sparkRect);
            ++it;
        }
    }
    
    bool allowActivePieceRender = !GameRenderer::isTransportActive() && !challengeClearActive;
    const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();

    float activePiecePixelOffsetX = 0.0f;
    if (allowActivePieceRender) {
        if (smoothScrollEnabled && !game->isPaused()) {
            const uint64_t pieceSeq = game->getCurrentPieceSequence();
            if (!s_activePieceSmooth.initialized || s_activePieceSmooth.sequence != pieceSeq) {
                s_activePieceSmooth.sequence = pieceSeq;
                s_activePieceSmooth.visualX = static_cast<float>(game->current().x);
                s_activePieceSmooth.initialized = true;
            }

            const float targetX = static_cast<float>(game->current().x);
            constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
            const float lerpFactor = std::clamp(sparkDeltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
            s_activePieceSmooth.visualX = std::lerp(s_activePieceSmooth.visualX, targetX, lerpFactor);
            activePiecePixelOffsetX = (s_activePieceSmooth.visualX - targetX) * finalBlockSize;
        } else {
            s_activePieceSmooth.sequence = game->getCurrentPieceSequence();
            s_activePieceSmooth.visualX = static_cast<float>(game->current().x);
            s_activePieceSmooth.initialized = true;
        }
    }

    auto computeFallOffset = [&]() -> float {
        if (game->isPaused()) {
            return 0.0f;
        }
        double gravityMs = game->getGravityMs();
        if (gravityMs <= 0.0) {
            return 0.0f;
        }
        double effectiveMs = game->isSoftDropping() ? std::max(5.0, gravityMs / 5.0) : gravityMs;
        double accumulator = std::clamp(game->getFallAccumulator(), 0.0, effectiveMs);
        if (effectiveMs <= 0.0) {
            return 0.0f;
        }
        float progress = static_cast<float>(accumulator / effectiveMs);
        progress = std::clamp(progress, 0.0f, 1.0f);
        return progress * finalBlockSize;
    };

    float activePiecePixelOffsetY = (!game->isPaused() && smoothScrollEnabled) ? computeFallOffset() : 0.0f;
    if (activePiecePixelOffsetY > 0.0f) {
        const auto& boardRef = game->boardRef();
        const Game::Piece& piece = game->current();
        float maxAllowed = finalBlockSize;
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!Game::cellFilled(piece, cx, cy)) {
                    continue;
                }
                int gx = piece.x + cx;
                int gy = piece.y + cy;
                if (gx < 0 || gx >= Game::COLS) {
                    continue;
                }
                int testY = gy + 1;
                int emptyRows = 0;
                if (testY < 0) {
                    emptyRows -= testY; // number of rows until we reach row 0
                    testY = 0;
                }
                while (testY >= 0 && testY < Game::ROWS) {
                    if (boardRef[testY * Game::COLS + gx] != 0) {
                        break;
                    }
                    ++emptyRows;
                    ++testY;
                }
                float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
                maxAllowed = std::min(maxAllowed, cellLimit);
            }
        }
        activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
    }

    // Debug: log single-player smoothing/fall values when enabled
    if (Settings::instance().isDebugEnabled()) {
        float sp_targetX = static_cast<float>(game->current().x);
        double sp_gravityMs = game->getGravityMs();
        double sp_fallAcc = game->getFallAccumulator();
        int sp_soft = game->isSoftDropping() ? 1 : 0;
        /*
        SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
            (unsigned long long)s_activePieceSmooth.sequence,
            s_activePieceSmooth.visualX,
            sp_targetX,
            activePiecePixelOffsetX,
            activePiecePixelOffsetY,
            sp_gravityMs,
            sp_fallAcc,
            sp_soft
        );
        */
    }

    // Draw ghost piece (where current piece will land)
    if (allowActivePieceRender) {
        Game::Piece ghostPiece = game->current();
        // Find landing position
        while (true) {
            Game::Piece testPiece = ghostPiece;
            testPiece.y++;
            bool collision = false;
            
            // Simple collision check
            for (int cy = 0; cy < 4; ++cy) {
                for (int cx = 0; cx < 4; ++cx) {
                    if (Game::cellFilled(testPiece, cx, cy)) {
                        int gx = testPiece.x + cx;
                        int gy = testPiece.y + cy;
                        if (gy >= Game::ROWS || gx < 0 || gx >= Game::COLS || 
                            (gy >= 0 && board[gy * Game::COLS + gx] != 0)) {
                            collision = true;
                            break;
                        }
                    }
                }
                if (collision) break;
            }
            
            if (collision) break;
            ghostPiece = testPiece;
        }
        
        // Draw ghost piece
        drawPiece(renderer, blocksTex, ghostPiece, gridX, gridY, finalBlockSize, true);
    }
    
    // Draw the falling piece
    if (allowActivePieceRender) {
        drawPiece(renderer, blocksTex, game->current(), gridX, gridY, finalBlockSize, false, activePiecePixelOffsetX, activePiecePixelOffsetY);
    }
    
    // Draw line clearing effects
    if (lineEffect && lineEffect->isActive()) {
        lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
    }
    
    // Draw block statistics (left panel) -> STATISTICS console
    const auto& blockCounts = game->getBlockCounts();
    int totalBlocks = 0;
    for (int i = 0; i < PIECE_COUNT; ++i) totalBlocks += blockCounts[i];

    // Header (slightly smaller)
    const SDL_Color headerColor{255, 220, 0, 255};
    const SDL_Color textColor{200, 220, 235, 200};
    const SDL_Color mutedColor{150, 180, 200, 180};
    // Small vertical shift to push text further into the statistics panel
    // Increased from 20 to 80 to move text down by ~60px more (within 50-100px range)
    const float statsTextShift = 80.0f;
    pixelFont->draw(renderer, statsX + 12.0f, statsY + 8.0f + statsTextShift, "STATISTICS", 0.70f, headerColor);

    // Tighter spacing and smaller icons/text for compact analytics console
    float yCursor = statsY + 34.0f + statsTextShift;
    const float leftPad = 2.0f;
    const float rightPad = 14.0f;
    // Horizontal shift to nudge the count/percent group closer to the right edge
    const float statsNumbersShift = 20.0f;
    // Horizontal shift to move the progress bar slightly left
    const float statsBarShift = -10.0f;
    // Increase row gap to avoid icon overlap on smaller scales (bumped up)
    const float rowGap = 28.0f;
    const float barHeight = 2.0f;

    // Text scales for per-row numbers (slightly larger than before)
    const float countScale = 0.60f;
    const float percScale = 0.60f;

    // Determine max percent to highlight top used piece
    int maxPerc = 0;
    for (int i = 0; i < PIECE_COUNT; ++i) {
        int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(blockCounts[i]) / double(totalBlocks))) : 0;
        if (perc > maxPerc) maxPerc = perc;
    }

    // Row order groups: first 4, then last 3
    std::vector<int> order = {0,1,2,3, 4,5,6};
    // Precompute max widths for counts/perc so right-alignment is stable
    int maxCountW = 0, maxCountH = 0;
    int maxPercW = 0, maxPercH = 0;
    for (int oi : order) {
        char tmpCount[16]; snprintf(tmpCount, sizeof(tmpCount), "%dx", blockCounts[oi]);
        char tmpPerc[16]; int tmpPercVal = (totalBlocks > 0) ? int(std::round(100.0 * double(blockCounts[oi]) / double(totalBlocks))) : 0; snprintf(tmpPerc, sizeof(tmpPerc), "%d%%", tmpPercVal);
        pixelFont->measure(tmpCount, countScale, maxCountW, maxCountH);
        int pw=0, ph=0; pixelFont->measure(tmpPerc, percScale, pw, ph);
        maxPercW = std::max(maxPercW, pw);
        maxPercH = std::max(maxPercH, ph);
    }

    for (size_t idx = 0; idx < order.size(); ++idx) {
        int i = order[idx];

        float rowLeft = statsX + leftPad;
        float rowRight = statsX + statsW - rightPad;

        // Icon aligned left, slightly higher to match reference spacing
        float iconSize = finalBlockSize * 0.52f;
        float iconBgPad = 6.0f;
        float iconBgX = rowLeft - 18.0f;
        float iconBgY = yCursor - 10.0f;

        // Measure right-side text first so we can vertically align icon with text
        int count = blockCounts[i];
        char countStr[16]; snprintf(countStr, sizeof(countStr), "%dx", count);
        int perc = (totalBlocks > 0) ? int(std::round(100.0 * double(count) / double(totalBlocks))) : 0;
        char percStr[16]; snprintf(percStr, sizeof(percStr), "%d%%", perc);

        int countW = maxCountW, countH = maxCountH;
        int percW = maxPercW, percH = maxPercH;

        float iconX = iconBgX + iconBgPad;
        float iconY = yCursor - 6.0f + ((float)countH - iconSize) * 0.5f;
        drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(i), iconX, iconY, iconSize);

        // Badge for counts/percent with no background, aligned to the right
        const float numbersGap = 14.0f;
        const float numbersPadX = 10.0f;
        const float numbersPadY = 6.0f;
        int maxTextH = std::max(countH, percH);
        float numbersW = numbersPadX * 2.0f + countW + numbersGap + percW;
        float numbersH = numbersPadY * 2.0f + static_cast<float>(maxTextH);
        float numbersX = rowRight - numbersW + statsNumbersShift;
        float numbersY = yCursor - (numbersH - static_cast<float>(maxTextH)) * 0.5f;

        float textY = numbersY + (numbersH - static_cast<float>(maxTextH)) * 0.5f;
        // Right-align the numbers block: perc anchored to the right, count right-aligned to the perc with gap
        float percX = numbersX + numbersW - percW - numbersPadX;
        float countX = percX - numbersGap - countW;
        pixelFont->draw(renderer, countX, textY, countStr, countScale, textColor);
        pixelFont->draw(renderer, percX, textY, percStr, percScale, mutedColor);

        // Progress bar anchored to the numbers width
        float barX = numbersX + statsBarShift;
        float barW = numbersW;
        float barY = numbersY + numbersH + 8.0f;

        rwrap->setDrawColor(SDL_Color{24, 80, 120, 220});
        SDL_FRect track{barX, barY, barW, barHeight};
        rwrap->fillRectF(&track);

        // Fill color brightness based on usage and highlight for top piece
        float strength = (totalBlocks > 0) ? (float(blockCounts[i]) / float(totalBlocks)) : 0.0f;
        SDL_Color baseC = {60, 200, 255, 255};
        SDL_Color dimC = {40, 120, 160, 255};
        SDL_Color fillC = (perc == maxPerc) ? SDL_Color{100, 230, 255, 255} : SDL_Color{
            static_cast<Uint8>(std::lerp((float)dimC.r, (float)baseC.r, strength)),
            static_cast<Uint8>(std::lerp((float)dimC.g, (float)baseC.g, strength)),
            static_cast<Uint8>(std::lerp((float)dimC.b, (float)baseC.b, strength)),
            255
        };

        float fillW = barW * std::clamp(strength, 0.0f, 1.0f);
        rwrap->setDrawColor(SDL_Color{fillC.r, fillC.g, fillC.b, fillC.a});
        SDL_FRect fill{barX, barY, fillW, barHeight};
        rwrap->fillRectF(&fill);

        // Advance cursor to next row: after bar + gap (leave more space between blocks)
        yCursor = barY + barHeight + rowGap + 6.0f;
    }

    // Bottom summary stats: prefer to place summary right after last row
    // but clamp it so it never goes below the reserved bottom area.
    float preferredSummaryY = yCursor + 24.0f; // space below last row
    float maxSummaryY = statsY + statsH - 90.0f; // original lower bound
    float summaryY = std::min(preferredSummaryY, maxSummaryY);
    const SDL_Color summaryValueColor{220, 235, 250, 255};
    const SDL_Color labelMuted{160, 180, 200, 200};

    char totalStr[32]; snprintf(totalStr, sizeof(totalStr), "%d", totalBlocks);
    char tetrisesStr[32]; snprintf(tetrisesStr, sizeof(tetrisesStr), "%d", game->tetrisesMade());
    char maxComboStr[32]; snprintf(maxComboStr, sizeof(maxComboStr), "%d", game->comboCount());

    // Use slightly smaller labels/values to match the compact look
    const float labelX = statsX + 8.0f; // move labels more left
    const float valueRightPad = 12.0f; // pad from right edge

    int valW=0, valH=0;
    pixelFont->measure(totalStr, 0.55f, valW, valH);
    float totalX = statsX + statsW - valueRightPad - (float)valW;
    pixelFont->draw(renderer, labelX, summaryY + 0.0f, "TOTAL PIECES", 0.46f, labelMuted);
    pixelFont->draw(renderer, totalX, summaryY + 0.0f, totalStr, 0.55f, summaryValueColor);

    pixelFont->measure(tetrisesStr, 0.55f, valW, valH);
    float tetrisesX = statsX + statsW - valueRightPad - (float)valW;
    pixelFont->draw(renderer, labelX, summaryY + 22.0f, "TETRISES MADE", 0.46f, labelMuted);
    pixelFont->draw(renderer, tetrisesX, summaryY + 22.0f, tetrisesStr, 0.55f, summaryValueColor);

    pixelFont->measure(maxComboStr, 0.55f, valW, valH);
    float comboX = statsX + statsW - valueRightPad - (float)valW;
    pixelFont->draw(renderer, labelX, summaryY + 44.0f, "COMBOS", 0.46f, labelMuted);
    pixelFont->draw(renderer, comboX, summaryY + 44.0f, maxComboStr, 0.55f, summaryValueColor);
    
    // Draw score panel (right side)
    const float contentTopOffset = 0.0f;
    const float contentBottomOffset = 290.0f;
    const float contentPad = 36.0f;
    float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
    float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;

    const float statsPanelGap = 12.0f;
    const float statsPanelLeft = gridX + GRID_W + statsPanelGap;
    const float statsPanelPadLeft = 40.0f;
    const float statsPanelPadRight = 34.0f;
    const float statsPanelPadY = 28.0f;
    const float statsTextX = statsPanelLeft + statsPanelPadLeft;

    const SDL_Color labelColor{255, 220, 0, 255};
    const SDL_Color valueColor{255, 255, 255, 255};
    const SDL_Color nextColor{80, 255, 120, 255};

    char scoreStr[32];
    snprintf(scoreStr, sizeof(scoreStr), "%d", game->score());

    char linesStr[16];
    snprintf(linesStr, sizeof(linesStr), "%03d", game->lines());

    char levelStr[16];
    snprintf(levelStr, sizeof(levelStr), "%02d", game->level());
    char challengeLevelStr[16];
    snprintf(challengeLevelStr, sizeof(challengeLevelStr), "%02d/100", game->challengeLevel());
    char asteroidStr[32];
    snprintf(asteroidStr, sizeof(asteroidStr), "%d LEFT", game->asteroidsRemaining());

    // Next level progress (endless only)
    int startLv = game->startLevelBase();
    int firstThreshold = (startLv + 1) * 10;
    int linesDone = game->lines();
    int nextThreshold = 0;
    if (linesDone < firstThreshold) {
        nextThreshold = firstThreshold;
    } else {
        int blocksPast = linesDone - firstThreshold;
        nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
    }
    int linesForNext = std::max(0, nextThreshold - linesDone);
    char nextStr[32];
    snprintf(nextStr, sizeof(nextStr), "%d  LINES", linesForNext);

    // Time display
    int totalSecs = static_cast<int>(game->elapsed());
    int mins = totalSecs / 60;
    int secs = totalSecs % 60;
    char timeStr[16];
    snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);

    const bool debugEnabled = Settings::instance().isDebugEnabled();
    char gravityStr[32] = "";
    char dropStr[32] = "";
    char gravityHud[64] = "";
    SDL_Color dropColor{100, 255, 100, 255};
    if (debugEnabled) {
        double gravityMs = game->getGravityMs();
        double fallAcc = game->getFallAccumulator();
        bool isSoftDrop = game->isSoftDropping();
        double effectiveGravityMs = isSoftDrop ? (gravityMs / 2.0) : gravityMs;
        double timeUntilDrop = std::max(0.0, effectiveGravityMs - fallAcc);

        snprintf(gravityStr, sizeof(gravityStr), "%.0f ms%s", gravityMs, isSoftDrop ? " (SD)" : "");
        snprintf(dropStr, sizeof(dropStr), "Drop: %.0f", timeUntilDrop);
        dropColor = isSoftDrop ? SDL_Color{255, 200, 100, 255} : SDL_Color{100, 255, 100, 255};

        double gfps = gravityMs > 0.0 ? (1000.0 / gravityMs) : 0.0;
        snprintf(gravityHud, sizeof(gravityHud), "GRAV: %.0f ms (%.2f fps)", gravityMs, gfps);
    }

    struct StatLine {
        const char* text;
        float offsetY;
        float scale;
        SDL_Color color;
    };

    std::vector<StatLine> statLines;
    statLines.reserve(debugEnabled ? 13 : 10);
    statLines.push_back({"SCORE", 0.0f, 1.0f, labelColor});
    statLines.push_back({scoreStr, 25.0f, 0.9f, valueColor});
    statLines.push_back({"LINES", 70.0f, 1.0f, labelColor});
    statLines.push_back({linesStr, 95.0f, 0.9f, valueColor});

    if (game->getMode() == GameMode::Challenge) {
        statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor});
        statLines.push_back({challengeLevelStr, 165.0f, 0.9f, valueColor});
        statLines.push_back({"ASTEROIDS", 200.0f, 1.0f, labelColor});
        statLines.push_back({asteroidStr, 225.0f, 0.9f, nextColor});
        statLines.push_back({"TIME", 265.0f, 1.0f, labelColor});
        statLines.push_back({timeStr, 290.0f, 0.9f, valueColor});
    } else {
        statLines.push_back({"LEVEL", 140.0f, 1.0f, labelColor});
        statLines.push_back({levelStr, 165.0f, 0.9f, valueColor});
        statLines.push_back({"NEXT LVL", 200.0f, 1.0f, labelColor});
        statLines.push_back({nextStr, 225.0f, 0.9f, nextColor});
        statLines.push_back({"TIME", 265.0f, 1.0f, labelColor});
        statLines.push_back({timeStr, 290.0f, 0.9f, valueColor});
    }

    if (debugEnabled) {
        SDL_Color debugLabelColor{150, 150, 150, 255};
        SDL_Color debugValueColor{180, 180, 180, 255};
        statLines.push_back({"GRAVITY", 330.0f, 0.8f, debugLabelColor});
        statLines.push_back({gravityStr, 350.0f, 0.7f, debugValueColor});
        statLines.push_back({dropStr, 370.0f, 0.7f, dropColor});
    }

    bool scorePanelMetricsValid = false;
    float scorePanelTop = 0.0f;
    float scorePanelLeftX = 0.0f;
    float scorePanelWidth = 0.0f;

    if (!statLines.empty()) {
        float statsContentTop = std::numeric_limits<float>::max();
        float statsContentBottom = std::numeric_limits<float>::lowest();
        float statsContentMaxWidth = 0.0f;

        for (const auto& line : statLines) {
            int textW = 0;
            int textH = 0;
            pixelFont->measure(line.text, line.scale, textW, textH);
            float y = baseY + line.offsetY;
            statsContentTop = std::min(statsContentTop, y);
            statsContentBottom = std::max(statsContentBottom, y + textH);
            statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast<float>(textW));
        }

        float statsPanelWidth = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight;
        float statsPanelHeight = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f;
        float statsPanelTop = statsContentTop - statsPanelPadY;

        SDL_FRect statsBg{statsPanelLeft, statsPanelTop, statsPanelWidth, statsPanelHeight};
        if (scorePanelTex) {
            rwrap->renderTexture(scorePanelTex, nullptr, &statsBg);
        } else {
            rwrap->setDrawColor(SDL_Color{12, 18, 32, 205});
            rwrap->fillRectF(&statsBg);
        }

        scorePanelMetricsValid = true;
        scorePanelTop = statsPanelTop;
        scorePanelLeftX = statsPanelLeft;
        scorePanelWidth = statsPanelWidth;
    }

    for (const auto& line : statLines) {
        pixelFont->draw(renderer, statsTextX, baseY + line.offsetY, line.text, line.scale, line.color);
    }

    // Challenge story / briefing line near level indicator
    if (challengeStoryText && !challengeStoryText->empty() && challengeStoryAlpha > 0.0f && game->getMode() == GameMode::Challenge) {
        float alpha = std::clamp(challengeStoryAlpha, 0.0f, 1.0f);
        SDL_Color storyColor{160, 220, 255, static_cast<Uint8>(std::lround(210.0f * alpha))};
        SDL_Color shadowColor{0, 0, 0, static_cast<Uint8>(std::lround(120.0f * alpha))};

        auto drawWrapped = [&](const std::string& text, float x, float y, float maxW, float scale, SDL_Color color) {
            std::istringstream iss(text);
            std::string word;
            std::string line;
            float cursorY = y;
            int lastH = 0;
            while (iss >> word) {
                std::string candidate = line.empty() ? word : (line + " " + word);
                int w = 0, h = 0;
                pixelFont->measure(candidate, scale, w, h);
                if (w > maxW && !line.empty()) {
                    pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor);
                    pixelFont->draw(renderer, x, cursorY, line, scale, color);
                    cursorY += h + 4.0f;
                    line = word;
                    lastH = h;
                } else {
                    line = candidate;
                    lastH = h;
                }
            }
            if (!line.empty()) {
                pixelFont->draw(renderer, x + 1.0f, cursorY + 1.0f, line, scale, shadowColor);
                pixelFont->draw(renderer, x, cursorY, line, scale, color);
                cursorY += lastH + 4.0f;
            }
        };

        float storyX = statsTextX;
        float storyY = baseY + 112.0f;
        float maxW = 230.0f;
        if (scorePanelMetricsValid && scorePanelWidth > 40.0f) {
            storyX = scorePanelLeftX + 14.0f;
            maxW = std::max(160.0f, scorePanelWidth - 28.0f);
        }

        drawWrapped(*challengeStoryText, storyX, storyY, maxW, 0.7f, storyColor);
    }

    if (debugEnabled) {
        pixelFont->draw(renderer, logicalW - 260, 10, gravityHud, 0.9f, {200, 200, 220, 255});
    }
    
    // Hold panel background & label (always visible). Small preview renders only if a piece is held.
    {
        float holdLabelX = statsTextX;
        float holdY = statsY + statsH - 80.0f;
        float holdBlockH = (finalBlockSize * 0.6f) * 6.0f;
        const float holdGap = 18.0f;
        float panelW = 120.0f;
        float panelH = holdBlockH + 12.0f;
        float panelX = holdLabelX + 40.0f;
        float panelY = holdY - 6.0f;

        if (scorePanelMetricsValid) {
            // align panel to score panel width and position it above it
            panelW = scorePanelWidth;
            panelX = scorePanelLeftX;
            panelY = scorePanelTop - panelH - holdGap;
            // choose label X (left edge + padding)
            holdLabelX = panelX + 10.0f;
            // label Y inside panel
            holdY = panelY + 8.0f;
        }

        if (holdPanelTex) {
            SDL_FRect panelDst{panelX, panelY, panelW, panelH};
            SDL_SetTextureBlendMode(holdPanelTex, SDL_BLENDMODE_BLEND);
            SDL_SetTextureScaleMode(holdPanelTex, SDL_SCALEMODE_LINEAR);
            rwrap->renderTexture(holdPanelTex, nullptr, &panelDst);
        } else {
            // fallback: draw a dark panel rect so UI is visible even without texture
            rwrap->setDrawColor(SDL_Color{12, 18, 32, 220});
            SDL_FRect panelDst{panelX, panelY, panelW, panelH};
            rwrap->fillRectF(&panelDst);
        }

        // Display "HOLD" label on right side
        pixelFont->draw(renderer, holdLabelX + 56.0f, holdY + 4.0f, "HOLD", 1.0f, {255, 220, 0, 255});

        if (game->held().type < PIECE_COUNT) {
            // Draw small held preview inside the panel (centered)
            float previewX = panelX + (panelW - (finalBlockSize * 0.6f * 4.0f)) * 0.5f;
            float previewY = panelY + (panelH - holdBlockH) * 2.5f;
            drawSmallPiece(renderer, blocksTex, static_cast<PieceType>(game->held().type), previewX, previewY, finalBlockSize * 0.6f);
        }
    }
    
    // Pause overlay logic moved to renderPauseOverlay

    
    // Exit popup logic moved to renderExitPopup
}

void GameRenderer::renderCoopPlayingState(
    SDL_Renderer* renderer,
    CoopGame* game,
    FontAtlas* pixelFont,
    LineEffect* lineEffect,
    SDL_Texture* blocksTex,
    SDL_Texture* statisticsPanelTex,
    SDL_Texture* scorePanelTex,
    SDL_Texture* nextPanelTex,
    SDL_Texture* holdPanelTex,
    bool paused,
    float logicalW,
    float logicalH,
    float logicalScale,
    float winW,
    float winH
) {
    if (!renderer || !game || !pixelFont) return;

    auto rwrap = renderer::MakeSDLRenderer(renderer);

    static SyncLineRenderer s_syncLine;
    static bool s_lastHadCompletedLines = false;

    static Uint32 s_lastCoopTick = SDL_GetTicks();
    Uint32 nowTicks = SDL_GetTicks();
    float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
    s_lastCoopTick = nowTicks;
    if (deltaMs < 0.0f || deltaMs > 100.0f) {
        deltaMs = 16.0f;
    }

    const float deltaSeconds = std::clamp(deltaMs / 1000.0f, 0.0f, 0.033f);
    s_syncLine.Update(deltaSeconds);

    const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
    struct SmoothState { bool initialized{false}; uint64_t seq{0}; float visualX{0.0f}; float visualY{0.0f}; };
    static SmoothState s_leftSmooth{};
    static SmoothState s_rightSmooth{};
    struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; int spawnY{0}; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; };
    static SpawnFadeState s_leftSpawnFade{};
    static SpawnFadeState s_rightSpawnFade{};

    // Layout constants (reuse single-player feel but sized for 20 cols)
    const float MIN_MARGIN = 40.0f;
    const float TOP_MARGIN = 60.0f;
    const float PANEL_WIDTH = 180.0f;
    const float PANEL_SPACING = 30.0f;
    const float NEXT_PANEL_HEIGHT = 120.0f;
    const float BOTTOM_MARGIN = 60.0f;

    // Content offset (centered logical viewport inside window)
    float contentScale = logicalScale;
    float contentW = logicalW * contentScale;
    float contentH = logicalH * contentScale;
    float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
    float contentOffsetY = (winH - contentH) * 0.5f / contentScale;

    auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
        rwrap->setDrawColor(c);
        SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
        rwrap->fillRectF(&fr);
    };

    static constexpr float COOP_GAP_PX = 20.0f;

    const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
    const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PANEL_HEIGHT;

    const float usableGridWidth = std::max(0.0f, availableWidth - COOP_GAP_PX);
    const float maxBlockSizeW = usableGridWidth / CoopGame::COLS;
    const float maxBlockSizeH = availableHeight / CoopGame::ROWS;
    const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
    const float finalBlockSize = std::max(16.0f, std::min(BLOCK_SIZE, 36.0f));

    const float HALF_W = 10.0f * finalBlockSize;
    const float GRID_W = CoopGame::COLS * finalBlockSize + COOP_GAP_PX;
    const float GRID_H = CoopGame::ROWS * finalBlockSize;

    const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
    const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
    const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
    const float contentStartY = TOP_MARGIN + verticalCenterOffset;

    const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
    const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;

    const float statsX = layoutStartX + contentOffsetX;
    const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
    const float gridY = contentStartY + NEXT_PANEL_HEIGHT + contentOffsetY;

    const float rightPanelX = gridX + GRID_W + PANEL_SPACING;

    const float statsY = gridY;
    const float statsW = PANEL_WIDTH;
    const float statsH = GRID_H;

    // (Score panels are drawn per-player below using scorePanelTex and classic sizing.)

    // Handle line clearing effects (defer to LineEffect like single-player)
    if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
        auto completedLines = game->getCompletedLines();
        lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), CoopGame::COLS, static_cast<int>(COOP_GAP_PX), 10);
        if (completedLines.size() == 4) {
            AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
        }
    }

    // Precompute row drop offsets (line collapse effect)
    std::array<float, CoopGame::ROWS> rowDropOffsets{};
    for (int y = 0; y < CoopGame::ROWS; ++y) {
        rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
    }

    // Grid backdrop and border (one border around both halves)
    drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
    // Background for left+right halves
    drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
    // Gap background (slightly darker so the 10px separation reads clearly)
    drawRectWithOffset(gridX + HALF_W - contentOffsetX, gridY - contentOffsetY, COOP_GAP_PX, GRID_H, {12, 14, 18, 255});

    // Sync divider line centered in the gap between halves.
    const float dividerCenterX = gridX + HALF_W + (COOP_GAP_PX * 0.5f);
    s_syncLine.SetRect(SDL_FRect{ dividerCenterX - 2.0f, gridY, 4.0f, GRID_H });

    auto cellX = [&](int col) -> float {
        float x = gridX + col * finalBlockSize;
        if (col >= 10) {
            x += COOP_GAP_PX;
        }
        return x;
    };

    // Grid lines (draw per-half so the gap is clean)
    rwrap->setDrawColor(SDL_Color{40, 45, 60, 255});
    for (int x = 1; x < 10; ++x) {
        float lineX = gridX + x * finalBlockSize;
        rwrap->renderLine(lineX, gridY, lineX, gridY + GRID_H);
    }
    for (int x = 1; x < 10; ++x) {
        float lineX = gridX + HALF_W + COOP_GAP_PX + x * finalBlockSize;
        rwrap->renderLine(lineX, gridY, lineX, gridY + GRID_H);
    }
    for (int y = 1; y < CoopGame::ROWS; ++y) {
        float lineY = gridY + y * finalBlockSize;
        rwrap->renderLine(gridX, lineY, gridX + HALF_W, lineY);
        rwrap->renderLine(gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY);
    }

    // In-grid 3D starfield + ambient sparkles (match classic feel, per-half)
    {
        static Uint32 s_lastCoopSparkTick = SDL_GetTicks();
        static std::mt19937 s_coopSparkRng{ std::random_device{}() };
        static std::vector<Sparkle> s_leftSparkles;
        static std::vector<Sparkle> s_rightSparkles;
        static std::vector<ImpactSpark> s_leftImpactSparks;
        static std::vector<ImpactSpark> s_rightImpactSparks;
        static float s_leftSparkleSpawnAcc = 0.0f;
        static float s_rightSparkleSpawnAcc = 0.0f;

        float halfW = HALF_W;
        const float leftGridX = gridX;
        const float rightGridX = gridX + HALF_W + COOP_GAP_PX;

        Uint32 sparkNow = nowTicks;
        float sparkDeltaMs = static_cast<float>(sparkNow - s_lastCoopSparkTick);
        s_lastCoopSparkTick = sparkNow;
        if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) {
            sparkDeltaMs = 16.0f;
        }

        if (!s_starfieldInitialized) {
            s_inGridStarfield.init(static_cast<int>(halfW), static_cast<int>(GRID_H), 180);
            s_starfieldInitialized = true;
        } else {
            s_inGridStarfield.resize(static_cast<int>(halfW), static_cast<int>(GRID_H));
        }

        const float deltaSeconds = std::clamp(sparkDeltaMs / 1000.0f, 0.0f, 0.033f);
        s_inGridStarfield.update(deltaSeconds);

        struct MagnetInfo { bool active{false}; float x{0.0f}; float y{0.0f}; };
        auto computeMagnet = [&](CoopGame::PlayerSide side) -> MagnetInfo {
            MagnetInfo info{};
            const CoopGame::Piece& activePiece = game->current(side);
            const int pieceType = static_cast<int>(activePiece.type);
            if (pieceType < 0 || pieceType >= PIECE_COUNT) {
                return info;
            }

            float sumLocalX = 0.0f;
            float sumLocalY = 0.0f;
            int filledCells = 0;
            const int localXOffsetCols = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
            for (int cy = 0; cy < 4; ++cy) {
                for (int cx = 0; cx < 4; ++cx) {
                    if (!CoopGame::cellFilled(activePiece, cx, cy)) continue;
                    sumLocalX += ((activePiece.x - localXOffsetCols) + cx + 0.5f) * finalBlockSize;
                    sumLocalY += (activePiece.y + cy + 0.5f) * finalBlockSize;
                    ++filledCells;
                }
            }
            if (filledCells <= 0) {
                return info;
            }

            info.active = true;
            info.x = std::clamp(sumLocalX / static_cast<float>(filledCells), 0.0f, halfW);
            info.y = std::clamp(sumLocalY / static_cast<float>(filledCells), 0.0f, GRID_H);
            return info;
        };

        const MagnetInfo leftMagnet = computeMagnet(CoopGame::PlayerSide::Left);
        const MagnetInfo rightMagnet = computeMagnet(CoopGame::PlayerSide::Right);

        SDL_BlendMode oldBlend = SDL_BLENDMODE_NONE;
        SDL_GetRenderDrawBlendMode(renderer, &oldBlend);
        SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);

        auto drawStarfieldHalf = [&](float originX, const MagnetInfo& magnet) {
            if (magnet.active) {
                const float magnetStrength = finalBlockSize * 2.2f;
                s_inGridStarfield.setMagnetTarget(magnet.x, magnet.y, magnetStrength);
            } else {
                s_inGridStarfield.clearMagnetTarget();
            }

            const float jitterAmp = 1.6f;
            const float tms = static_cast<float>(sparkNow) * 0.001f;
            const float jitterX = std::sin(tms * 1.7f) * jitterAmp + std::cos(tms * 0.9f) * 0.4f;
            const float jitterY = std::sin(tms * 1.1f + 3.7f) * (jitterAmp * 0.6f);
            s_inGridStarfield.draw(renderer, originX + jitterX, gridY + jitterY, 0.22f, true);
        };

        drawStarfieldHalf(leftGridX, leftMagnet);
        drawStarfieldHalf(rightGridX, rightMagnet);

        auto updateAndDrawSparkleLayer = [&](std::vector<Sparkle>& sparkles,
                                             std::vector<ImpactSpark>& impactSparks,
                                             float& spawnAcc,
                                             const MagnetInfo& magnet,
                                             float originX) {
            if (!paused) {
                const float spawnInterval = 0.08f;
                spawnAcc += deltaSeconds;
                while (spawnAcc >= spawnInterval) {
                    spawnAcc -= spawnInterval;

                    Sparkle s;
                    bool spawnNearPiece = magnet.active && (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) > 0.35f);

                    float sx = 0.0f;
                    float sy = 0.0f;
                    if (spawnNearPiece) {
                        float jitterX = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
                        float jitterY = std::uniform_real_distribution<float>(-finalBlockSize * 1.2f, finalBlockSize * 1.2f)(s_coopSparkRng);
                        sx = std::clamp(magnet.x + jitterX, -finalBlockSize * 2.0f, halfW + finalBlockSize * 2.0f);
                        sy = std::clamp(magnet.y + jitterY, -finalBlockSize * 2.0f, GRID_H + finalBlockSize * 2.0f);
                    } else {
                        float side = std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng);
                        const float borderBand = std::max(12.0f, finalBlockSize * 1.0f);
                        if (side < 0.2f) {
                            sx = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
                            sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
                        } else if (side < 0.4f) {
                            sx = std::uniform_real_distribution<float>(halfW, halfW + borderBand)(s_coopSparkRng);
                            sy = std::uniform_real_distribution<float>(-borderBand, GRID_H + borderBand)(s_coopSparkRng);
                        } else if (side < 0.6f) {
                            sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
                            sy = std::uniform_real_distribution<float>(-borderBand, 0.0f)(s_coopSparkRng);
                        } else if (side < 0.9f) {
                            sx = std::uniform_real_distribution<float>(0.0f, halfW)(s_coopSparkRng);
                            sy = std::uniform_real_distribution<float>(0.0f, finalBlockSize * 2.0f)(s_coopSparkRng);
                        } else {
                            sx = std::uniform_real_distribution<float>(-borderBand, halfW + borderBand)(s_coopSparkRng);
                            sy = std::uniform_real_distribution<float>(GRID_H, GRID_H + borderBand)(s_coopSparkRng);
                        }
                    }

                    s.x = sx;
                    s.y = sy;
                    float speed = std::uniform_real_distribution<float>(10.0f, 60.0f)(s_coopSparkRng);
                    float ang = std::uniform_real_distribution<float>(-3.14159f, 3.14159f)(s_coopSparkRng);
                    s.vx = std::cos(ang) * speed;
                    s.vy = std::sin(ang) * speed * 0.25f;
                    s.maxLifeMs = std::uniform_real_distribution<float>(350.0f, 900.0f)(s_coopSparkRng);
                    s.lifeMs = s.maxLifeMs;
                    s.size = std::uniform_real_distribution<float>(1.5f, 5.0f)(s_coopSparkRng);
                    if (std::uniform_real_distribution<float>(0.0f, 1.0f)(s_coopSparkRng) < 0.5f) {
                        s.color = SDL_Color{255, 230, 180, 255};
                    } else {
                        s.color = SDL_Color{180, 220, 255, 255};
                    }
                    s.pulse = std::uniform_real_distribution<float>(0.0f, 6.28f)(s_coopSparkRng);
                    sparkles.push_back(s);
                }
            }

            if (!sparkles.empty()) {
                auto it = sparkles.begin();
                while (it != sparkles.end()) {
                    Sparkle& sp = *it;
                    sp.lifeMs -= sparkDeltaMs;
                    if (sp.lifeMs <= 0.0f) {
                        const int burstCount = std::uniform_int_distribution<int>(4, 8)(s_coopSparkRng);
                        for (int bi = 0; bi < burstCount; ++bi) {
                            ImpactSpark ps;
                            ps.x = originX + sp.x + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
                            ps.y = gridY + sp.y + std::uniform_real_distribution<float>(-2.0f, 2.0f)(s_coopSparkRng);
                            float ang = std::uniform_real_distribution<float>(0.0f, 6.2831853f)(s_coopSparkRng);
                            float speed = std::uniform_real_distribution<float>(10.0f, 120.0f)(s_coopSparkRng);
                            ps.vx = std::cos(ang) * speed;
                            ps.vy = std::sin(ang) * speed * 0.8f;
                            ps.maxLifeMs = std::uniform_real_distribution<float>(220.0f, 500.0f)(s_coopSparkRng);
                            ps.lifeMs = ps.maxLifeMs;
                            ps.size = std::max(1.0f, sp.size * 0.5f);
                            ps.color = sp.color;
                            impactSparks.push_back(ps);
                        }
                        it = sparkles.erase(it);
                        continue;
                    }

                    float lifeRatio = sp.lifeMs / sp.maxLifeMs;
                    sp.x += sp.vx * deltaSeconds;
                    sp.y += sp.vy * deltaSeconds;
                    sp.vy *= 0.995f;
                    sp.pulse += deltaSeconds * 8.0f;

                    float pulse = 0.5f + 0.5f * std::sin(sp.pulse);
                    Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio * pulse, 0.0f, 1.0f) * 255.0f);
                    rwrap->setDrawColor(SDL_Color{sp.color.r, sp.color.g, sp.color.b, alpha});
                    float half = sp.size * 0.5f;
                    SDL_FRect fr{ originX + sp.x - half, gridY + sp.y - half, sp.size, sp.size };
                    rwrap->fillRectF(&fr);
                    ++it;
                }
            }

            if (!impactSparks.empty()) {
                auto it = impactSparks.begin();
                while (it != impactSparks.end()) {
                    ImpactSpark& spark = *it;
                    spark.vy += 0.00045f * sparkDeltaMs;
                    spark.x += spark.vx * sparkDeltaMs;
                    spark.y += spark.vy * sparkDeltaMs;
                    spark.lifeMs -= sparkDeltaMs;
                    if (spark.lifeMs <= 0.0f) {
                        it = impactSparks.erase(it);
                        continue;
                    }
                    float lifeRatio = spark.lifeMs / spark.maxLifeMs;
                    Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
                    rwrap->setDrawColor(SDL_Color{spark.color.r, spark.color.g, spark.color.b, alpha});
                    SDL_FRect sparkRect{
                        spark.x - spark.size * 0.5f,
                        spark.y - spark.size * 0.5f,
                        spark.size,
                        spark.size * 1.4f
                    };
                    rwrap->fillRectF(&sparkRect);
                    ++it;
                }
            }
        };

        updateAndDrawSparkleLayer(s_leftSparkles, s_leftImpactSparks, s_leftSparkleSpawnAcc, leftMagnet, leftGridX);
        updateAndDrawSparkleLayer(s_rightSparkles, s_rightImpactSparks, s_rightSparkleSpawnAcc, rightMagnet, rightGridX);

        SDL_SetRenderDrawBlendMode(renderer, oldBlend);
    }

    // Half-row feedback: lightly tint rows where one side is filled, brighter where both are pending clear
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
    const auto& rowStates = game->rowHalfStates();

    bool leftReady = false;
    bool rightReady = false;
    bool synced = false;

    for (int y = 0; y < CoopGame::ROWS; ++y) {
        const auto& rs = rowStates[y];
        float rowY = gridY + y * finalBlockSize;

        if (rs.leftFull && rs.rightFull) {
            synced = true;
        } else {
            leftReady = leftReady || (rs.leftFull && !rs.rightFull);
            rightReady = rightReady || (rs.rightFull && !rs.leftFull);
        }

        if (rs.leftFull && rs.rightFull) {
            rwrap->setDrawColor(SDL_Color{140, 210, 255, 45});
            SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize};
            rwrap->fillRectF(&frL);
            SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize};
            rwrap->fillRectF(&frR);
        } else if (rs.leftFull ^ rs.rightFull) {
            rwrap->setDrawColor(SDL_Color{90, 140, 220, 35});
            float w = HALF_W;
            float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX);
            SDL_FRect fr{x, rowY, w, finalBlockSize};
            rwrap->fillRectF(&fr);
        }
    }
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);

    // Trigger a brief flash exactly when cooperative lines are actually cleared:
    // `completedLines` remains populated during the LineEffect, then becomes empty
    // immediately after `CoopGame::clearCompletedLines()` is invoked.
    const bool hasCompletedLines = game->hasCompletedLines();
    if (s_lastHadCompletedLines && !hasCompletedLines) {
        s_syncLine.TriggerClearFlash();
    }
    s_lastHadCompletedLines = hasCompletedLines;

    if (synced) {
        s_syncLine.SetState(SyncState::Synced);
    } else if (leftReady) {
        s_syncLine.SetState(SyncState::LeftReady);
    } else if (rightReady) {
        s_syncLine.SetState(SyncState::RightReady);
    } else {
        s_syncLine.SetState(SyncState::Idle);
    }

    // Hard-drop impact shake (match classic feel)
    float impactStrength = 0.0f;
    float impactEased = 0.0f;
    std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> impactMask{};
    std::array<float, CoopGame::COLS * CoopGame::ROWS> impactWeight{};
    if (game->hasHardDropShake()) {
        impactStrength = static_cast<float>(game->hardDropShakeStrength());
        impactStrength = std::clamp(impactStrength, 0.0f, 1.0f);
        impactEased = impactStrength * impactStrength;
        const auto& impactCells = game->getHardDropCells();
        const auto& boardRef = game->boardRef();
        for (const auto& cell : impactCells) {
            if (cell.x < 0 || cell.x >= CoopGame::COLS || cell.y < 0 || cell.y >= CoopGame::ROWS) {
                continue;
            }
            int idx = cell.y * CoopGame::COLS + cell.x;
            impactMask[idx] = 1;
            impactWeight[idx] = 1.0f;

            int depth = 0;
            for (int ny = cell.y + 1; ny < CoopGame::ROWS && depth < 4; ++ny) {
                if (!boardRef[ny * CoopGame::COLS + cell.x].occupied) {
                    break;
                }
                ++depth;
                int nidx = ny * CoopGame::COLS + cell.x;
                impactMask[nidx] = 1;
                float weight = std::max(0.15f, 1.0f - depth * 0.35f);
                impactWeight[nidx] = std::max(impactWeight[nidx], weight);
            }
        }
    }

    // Draw settled blocks
    const auto& board = game->boardRef();
    for (int y = 0; y < CoopGame::ROWS; ++y) {
        float dropOffset = rowDropOffsets[y];
        for (int x = 0; x < CoopGame::COLS; ++x) {
            const auto& cell = board[y * CoopGame::COLS + x];
            if (!cell.occupied || cell.value <= 0) continue;
            float px = cellX(x);
            float py = gridY + y * finalBlockSize + dropOffset;

            const int cellIdx = y * CoopGame::COLS + x;
            float weight = impactWeight[cellIdx];
            if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) {
                float cellSeed = static_cast<float>((x * 37 + y * 61) % 113);
                float t = static_cast<float>(nowTicks % 10000) * 0.018f + cellSeed;
                float amplitude = 6.0f * impactEased * weight;
                float freq = 2.0f + weight * 1.3f;
                px += amplitude * std::sin(t * freq);
                py += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
            }

            drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1);
        }
    }

    // Active pieces (per-side smoothing)
    auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) {
        float offsetX = 0.0f;
        float offsetY = 0.0f;

        if (smoothScrollEnabled) {
            const uint64_t seq = game->currentPieceSequence(side);
            const float targetX = static_cast<float>(game->current(side).x);
            if (!ss.initialized || ss.seq != seq) {
                ss.initialized = true;
                ss.seq = seq;
                ss.visualX = targetX;
                // Trigger a short spawn fade so the newly spawned piece visually
                // fades into the first visible row (like classic mode).
                SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade;
                sf.active = true;
                sf.startTick = nowTicks;
                sf.durationMs = 200.0f;
                sf.seq = seq;
                sf.piece = game->current(side);
                sf.spawnY = sf.piece.y;
                sf.tileSize = finalBlockSize;
                // Note: during the spawn fade we draw the live piece each frame.
                // If the piece is still above the visible grid, we temporarily pin
                // it so the topmost filled cell appears at row 0 (no spawn delay),
                // while still applying smoothing offsets so it starts moving
                // immediately.
                sf.targetX = 0.0f;
                sf.targetY = 0.0f;
            } else {
                // Reuse exact horizontal smoothing from single-player
                constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
                const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
                ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor);
            }
            offsetX = (ss.visualX - targetX) * finalBlockSize;

            // Reuse exact single-player fall offset computation (per-side getters)
            double gravityMs = game->getGravityMs();
            if (gravityMs > 0.0) {
                double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs;
                double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs);
                float progress = static_cast<float>(accumulator / effectiveMs);
                progress = std::clamp(progress, 0.0f, 1.0f);
                offsetY = progress * finalBlockSize;

                // Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player)
                const auto& boardRef = game->boardRef();
                const CoopGame::Piece& piece = game->current(side);
                float maxAllowed = finalBlockSize;
                for (int cy = 0; cy < 4; ++cy) {
                    for (int cx = 0; cx < 4; ++cx) {
                        if (!CoopGame::cellFilled(piece, cx, cy)) continue;
                        int gx = piece.x + cx;
                        int gy = piece.y + cy;
                        if (gx < 0 || gx >= CoopGame::COLS) continue;
                        int testY = gy + 1;
                        int emptyRows = 0;
                        if (testY < 0) {
                            emptyRows -= testY;
                            testY = 0;
                        }
                        while (testY >= 0 && testY < CoopGame::ROWS) {
                            if (boardRef[testY * CoopGame::COLS + gx].occupied) break;
                            ++emptyRows;
                            ++testY;
                        }
                        float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
                        maxAllowed = std::min(maxAllowed, cellLimit);
                    }
                }
                offsetY = std::min(offsetY, maxAllowed);
            }
        } else {
            ss.initialized = true;
            ss.seq = game->currentPieceSequence(side);
            ss.visualX = static_cast<float>(game->current(side).x);
        }

        if (Settings::instance().isDebugEnabled()) {
            float dbg_targetX = static_cast<float>(game->current(side).x);
            double gMsDbg = game->getGravityMs();
            double accDbg = game->getFallAccumulator(side);
            int softDbg = game->isSoftDropping(side) ? 1 : 0;
            /*
            SDL_LogDebug(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
                (side == CoopGame::PlayerSide::Left) ? "L" : "R",
                (unsigned long long)ss.seq,
                ss.visualX,
                dbg_targetX,
                offsetX,
                offsetY,
                gMsDbg,
                accDbg,
                softDbg
            );
            */
        }
        return std::pair<float, float>{ offsetX, offsetY };
    };

    auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf, CoopGame::PlayerSide side, const std::pair<float, float>& offsets) {
        if (!sf.active) return;

        // If the piece has already changed, stop the fade.
        const uint64_t currentSeq = game->currentPieceSequence(side);
        if (sf.seq != currentSeq) {
            sf.active = false;
            return;
        }

        const CoopGame::Piece& livePiece = game->current(side);
        float elapsed = static_cast<float>(nowTicks - sf.startTick);
        float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
        Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
        if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, alpha);

        int minCy = 4;
        int maxCy = -1;
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
                minCy = std::min(minCy, cy);
                maxCy = std::max(maxCy, cy);
            }
        }
        if (minCy == 4) {
            minCy = 0;
        }
        if (maxCy < 0) {
            maxCy = 0;
        }

        // Pin only when *no* filled cell is visible yet. Using maxCy avoids pinning
        // cases like vertical I where some blocks are already visible at spawn.
        const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0;

        const float baseX = cellX(livePiece.x) + offsets.first;
        float baseY = 0.0f;
        if (pinToFirstVisibleRow) {
            // Keep the piece visible (topmost filled cell at row 0), but also
            // incorporate real y-step progression so the fall accumulator wrapping
            // doesn't produce a one-row snap.
            const int dySteps = livePiece.y - sf.spawnY;
            baseY = (gridY - static_cast<float>(minCy) * sf.tileSize)
                + static_cast<float>(dySteps) * sf.tileSize
                + offsets.second;
        } else {
            baseY = gridY + static_cast<float>(livePiece.y) * sf.tileSize + offsets.second;
        }

        // Draw the live piece (either pinned-to-row0 or at its real position).
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!CoopGame::cellFilled(livePiece, cx, cy)) continue;
                int pyIdx = livePiece.y + cy;
                if (!pinToFirstVisibleRow && pyIdx < 0) continue;
                float px = baseX + static_cast<float>(cx) * sf.tileSize;
                float py = baseY + static_cast<float>(cy) * sf.tileSize;
                drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, livePiece.type);
            }
        }
        if (blocksTex) rwrap->setTextureAlphaMod(blocksTex, 255);

        // End fade after duration, but never stop while we are pinning (otherwise
        // I can briefly disappear until it becomes visible in the real grid).
        if (t >= 1.0f && !pinToFirstVisibleRow) {
            sf.active = false;
        }
    };

    auto drawPiece = [&](const CoopGame::Piece& p, const std::pair<float, float>& offsets, bool isGhost) {
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!CoopGame::cellFilled(p, cx, cy)) continue;
                int pxIdx = p.x + cx;
                int pyIdx = p.y + cy;
                if (pyIdx < 0) continue; // don't draw parts above the visible grid
                float px = cellX(pxIdx) + offsets.first;
                float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
                if (isGhost) {
                    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
                    rwrap->setDrawColor(SDL_Color{180, 180, 180, 20});
                    SDL_FRect rect = {px + 2.0f, py + 2.0f, finalBlockSize - 4.0f, finalBlockSize - 4.0f};
                    rwrap->fillRectF(&rect);
                    rwrap->setDrawColor(SDL_Color{180, 180, 180, 30});
                    SDL_FRect border = {px + 1.0f, py + 1.0f, finalBlockSize - 2.0f, finalBlockSize - 2.0f};
                    rwrap->drawRectF(&border);
                } else {
                    drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
                }
            }
        }
    };
    const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth);
    const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth);
    // Draw transient spawn fades (if active)
    drawSpawnFadeIfActive(s_leftSpawnFade, CoopGame::PlayerSide::Left, leftOffsets);
    drawSpawnFadeIfActive(s_rightSpawnFade, CoopGame::PlayerSide::Right, rightOffsets);

    // Draw classic-style ghost pieces (landing position), grid-aligned.
    // This intentionally does NOT use smoothing offsets.
    auto computeGhostPiece = [&](CoopGame::PlayerSide side) {
        CoopGame::Piece ghostPiece = game->current(side);
        const auto& boardRef = game->boardRef();
        while (true) {
            CoopGame::Piece testPiece = ghostPiece;
            testPiece.y++;
            bool collision = false;
            for (int cy = 0; cy < 4; ++cy) {
                for (int cx = 0; cx < 4; ++cx) {
                    if (!CoopGame::cellFilled(testPiece, cx, cy)) continue;
                    int gx = testPiece.x + cx;
                    int gy = testPiece.y + cy;
                    if (gy >= CoopGame::ROWS || gx < 0 || gx >= CoopGame::COLS ||
                        (gy >= 0 && boardRef[gy * CoopGame::COLS + gx].occupied)) {
                        collision = true;
                        break;
                    }
                }
                if (collision) break;
            }
            if (collision) break;
            ghostPiece = testPiece;
        }
        return ghostPiece;
    };

    const std::pair<float, float> ghostOffsets{0.0f, 0.0f};
    drawPiece(computeGhostPiece(CoopGame::PlayerSide::Left), ghostOffsets, true);
    drawPiece(computeGhostPiece(CoopGame::PlayerSide::Right), ghostOffsets, true);

    // If a spawn fade is active for a side and matches the current piece
    // sequence, only draw the fade visual and skip the regular piece draw
    // to avoid a double-draw that appears as a jump when falling starts.
    if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) {
        drawPiece(game->current(CoopGame::PlayerSide::Left), leftOffsets, false);
    }
    if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) {
        drawPiece(game->current(CoopGame::PlayerSide::Right), rightOffsets, false);
    }

    // Draw line clearing effects above pieces (matches single-player)
    if (lineEffect && lineEffect->isActive()) {
        lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), static_cast<int>(COOP_GAP_PX), 10);
    }

    // Render the SYNC divider last so it stays visible above effects/blocks.
    s_syncLine.Render(renderer);
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);

    // Next panels (two)
    const float nextPanelPad = 12.0f;
    const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f;
    const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
    float nextLeftX = gridX + finalBlockSize;
    float nextRightX = gridX + HALF_W + COOP_GAP_PX + (HALF_W - finalBlockSize - nextPanelW);
    float nextY = contentStartY + contentOffsetY;

    auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
        SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
        if (nextPanelTex) {
            rwrap->renderTexture(nextPanelTex, nullptr, &panel);
        } else {
            drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
        }
        // Center piece inside panel
        int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1;
        for (int cy = 0; cy < 4; ++cy) {
            for (int cx = 0; cx < 4; ++cx) {
                if (!CoopGame::cellFilled(piece, cx, cy)) continue;
                minCx = std::min(minCx, cx);
                minCy = std::min(minCy, cy);
                maxCx = std::max(maxCx, cx);
                maxCy = std::max(maxCy, cy);
            }
        }
        if (maxCx >= minCx && maxCy >= minCy) {
            float tile = finalBlockSize * 0.8f;
            float pieceW = (maxCx - minCx + 1) * tile;
            float pieceH = (maxCy - minCy + 1) * tile;
            float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile;
            float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile;
            for (int cy = 0; cy < 4; ++cy) {
                for (int cx = 0; cx < 4; ++cx) {
                    if (!CoopGame::cellFilled(piece, cx, cy)) continue;
                    float px = startX + cx * tile;
                    float py = startY + cy * tile;
                    drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type);
                }
            }
        }
    };

    drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left));
    drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right));

    // Per-player scoreboards (left and right)
    auto drawPlayerScoreboard = [&](CoopGame::PlayerSide side, float columnLeftX, float columnRightX, const char* title) {
        const SDL_Color labelColor{255, 220, 0, 255};
        const SDL_Color valueColor{255, 255, 255, 255};
        const SDL_Color nextColor{80, 255, 120, 255};

        // Match classic vertical placement feel
        const float contentTopOffset = 0.0f;
        const float contentBottomOffset = 290.0f;
        const float contentPad = 36.0f;
        float scoreContentH = (contentBottomOffset - contentTopOffset) + contentPad;
        float baseY = gridY + (GRID_H - scoreContentH) * 0.5f;

        const float statsPanelPadLeft = 40.0f;
        const float statsPanelPadRight = 34.0f;
        const float statsPanelPadY = 28.0f;

        const float textX = columnLeftX + statsPanelPadLeft;

        char scoreStr[32];
        std::snprintf(scoreStr, sizeof(scoreStr), "%d", game->score(side));

        char linesStr[16];
        std::snprintf(linesStr, sizeof(linesStr), "%03d", game->lines(side));

        char levelStr[16];
        std::snprintf(levelStr, sizeof(levelStr), "%02d", game->level(side));

        // Next level progression (per-player lines)
        int startLv = game->startLevelBase();
        int linesDone = game->lines(side);
        int firstThreshold = (startLv + 1) * 10;
        int nextThreshold = 0;
        if (linesDone < firstThreshold) {
            nextThreshold = firstThreshold;
        } else {
            int blocksPast = linesDone - firstThreshold;
            nextThreshold = firstThreshold + ((blocksPast / 10) + 1) * 10;
        }
        int linesForNext = std::max(0, nextThreshold - linesDone);
        char nextStr[32];
        std::snprintf(nextStr, sizeof(nextStr), "%d  LINES", linesForNext);

        // Time display (shared session time)
        int totalSecs = game->elapsed(side);
        int mins = totalSecs / 60;
        int secs = totalSecs % 60;
        char timeStr[16];
        std::snprintf(timeStr, sizeof(timeStr), "%02d:%02d", mins, secs);

        struct StatLine {
            const char* text;
            float offsetY;
            float scale;
            SDL_Color color;
        };

        // Keep offsets aligned with classic spacing
        std::vector<StatLine> statLines;
        statLines.reserve(12);
        statLines.push_back({title, 0.0f, 0.95f, SDL_Color{200, 220, 235, 220}});
        statLines.push_back({"SCORE", 30.0f, 1.0f, labelColor});
        statLines.push_back({scoreStr, 55.0f, 0.9f, valueColor});
        statLines.push_back({"LINES", 100.0f, 1.0f, labelColor});
        statLines.push_back({linesStr, 125.0f, 0.9f, valueColor});
        statLines.push_back({"LEVEL", 170.0f, 1.0f, labelColor});
        statLines.push_back({levelStr, 195.0f, 0.9f, valueColor});
        statLines.push_back({"NEXT LVL", 230.0f, 1.0f, labelColor});
        statLines.push_back({nextStr, 255.0f, 0.9f, nextColor});
        statLines.push_back({"TIME", 295.0f, 1.0f, labelColor});
        statLines.push_back({timeStr, 320.0f, 0.9f, valueColor});

        // Size the panel like classic: measure the text block and fit the background.
        float statsContentTop = std::numeric_limits<float>::max();
        float statsContentBottom = std::numeric_limits<float>::lowest();
        float statsContentMaxWidth = 0.0f;
        for (const auto& line : statLines) {
            int textW = 0;
            int textH = 0;
            pixelFont->measure(line.text, line.scale, textW, textH);
            float y = baseY + line.offsetY;
            statsContentTop = std::min(statsContentTop, y);
            statsContentBottom = std::max(statsContentBottom, y + static_cast<float>(textH));
            statsContentMaxWidth = std::max(statsContentMaxWidth, static_cast<float>(textW));
        }

        float panelW = statsPanelPadLeft + statsContentMaxWidth + statsPanelPadRight;
        float panelH = (statsContentBottom - statsContentTop) + statsPanelPadY * 2.0f;
        float panelY = statsContentTop - statsPanelPadY;
        // Left player is left-aligned in its column; right player is right-aligned.
        float panelX = (side == CoopGame::PlayerSide::Right) ? (columnRightX - panelW) : columnLeftX;
        SDL_FRect panelBg{ panelX, panelY, panelW, panelH };
        if (scorePanelTex) {
            rwrap->renderTexture(scorePanelTex, nullptr, &panelBg);
        } else {
            rwrap->setDrawColor(SDL_Color{12, 18, 32, 205});
            rwrap->fillRectF(&panelBg);
        }

        float textDrawX = panelX + statsPanelPadLeft;
        for (const auto& line : statLines) {
            pixelFont->draw(renderer, textDrawX, baseY + line.offsetY, line.text, line.scale, line.color);
        }
    };

    // Nudge panels toward the window edges for tighter symmetry.
    const float scorePanelEdgeNudge = 20.0f;
    const float leftColumnLeftX = statsX - scorePanelEdgeNudge;
    const float leftColumnRightX = leftColumnLeftX + statsW;
    const float rightColumnLeftX = rightPanelX;
    const float rightColumnRightX = rightColumnLeftX + statsW + scorePanelEdgeNudge;

    drawPlayerScoreboard(CoopGame::PlayerSide::Left, leftColumnLeftX, leftColumnRightX, "PLAYER 1");
    drawPlayerScoreboard(CoopGame::PlayerSide::Right, rightColumnLeftX, rightColumnRightX, "PLAYER 2");

    // Combined score summary centered under the grid
    {
        int leftScore = game->score(CoopGame::PlayerSide::Left);
        int rightScore = game->score(CoopGame::PlayerSide::Right);
        int sumScore = leftScore + rightScore;
        char sumLabel[64];
        char sumValue[64];
        std::snprintf(sumLabel, sizeof(sumLabel), "SCORE %d + SCORE %d =", leftScore, rightScore);
        std::snprintf(sumValue, sizeof(sumValue), "%d", sumScore);

        // Draw label smaller and value larger
        float labelScale = 0.9f;
        float valueScale = 1.6f;
        SDL_Color labelColor = {200, 220, 235, 220};
        SDL_Color valueColor = {255, 230, 130, 255};

        // Position: centered beneath the grid
        float centerX = gridX + GRID_W * 0.5f;
        int lw=0, lh=0; pixelFont->measure(sumLabel, labelScale, lw, lh);
        int vw=0, vh=0; pixelFont->measure(sumValue, valueScale, vw, vh);
        float labelX = centerX - static_cast<float>(lw) * 0.5f;
        float valueX = centerX - static_cast<float>(vw) * 0.5f;
        float belowY = gridY + GRID_H + 14.0f; // small gap below grid

        pixelFont->draw(renderer, labelX, belowY, sumLabel, labelScale, labelColor);
        pixelFont->draw(renderer, valueX, belowY + 22.0f, sumValue, valueScale, valueColor);
    }
}

void GameRenderer::renderExitPopup(
    SDL_Renderer* renderer,
    FontAtlas* pixelFont,
    float winW,
    float winH,
    float logicalScale,
    int selectedButton
) {
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);

    SDL_Rect oldViewport;
    SDL_GetRenderViewport(renderer, &oldViewport);
    float oldScaleX = 1.0f;
    float oldScaleY = 1.0f;
    SDL_GetRenderScale(renderer, &oldScaleX, &oldScaleY);

    SDL_SetRenderViewport(renderer, nullptr);
    SDL_SetRenderScale(renderer, 1.0f, 1.0f);

    auto rwrap = renderer::MakeSDLRenderer(renderer);
    rwrap->setDrawColor(SDL_Color{2, 4, 12, 210});
    SDL_FRect fullWin{0.0f, 0.0f, winW, winH};
    rwrap->fillRectF(&fullWin);

    const float scale = std::max(0.8f, logicalScale);
    const float panelW = 740.0f * scale;
    const float panelH = 380.0f * scale;
    SDL_FRect panel{
        (winW - panelW) * 0.5f,
        (winH - panelH) * 0.5f,
        panelW,
        panelH
    };

    SDL_FRect shadow{
        panel.x + 14.0f * scale,
        panel.y + 16.0f * scale,
        panel.w + 4.0f * scale,
        panel.h + 4.0f * scale
    };
    rwrap->setDrawColor(SDL_Color{0, 0, 0, 140});
    rwrap->fillRectF(&shadow);

    const std::array<SDL_Color, 3> panelLayers{
        SDL_Color{7, 10, 22, 255},
        SDL_Color{12, 22, 40, 255},
        SDL_Color{18, 32, 56, 255}
    };
    for (size_t i = 0; i < panelLayers.size(); ++i) {
        float inset = static_cast<float>(i) * 6.0f * scale;
        SDL_FRect layer{
            panel.x + inset,
            panel.y + inset,
            panel.w - inset * 2.0f,
            panel.h - inset * 2.0f
        };
        SDL_Color c = panelLayers[i];
        rwrap->setDrawColor(c);
        rwrap->fillRectF(&layer);
    }

    rwrap->setDrawColor(SDL_Color{60, 90, 150, 255});
    rwrap->drawRectF(&panel);

    SDL_FRect insetFrame{
        panel.x + 10.0f * scale,
        panel.y + 10.0f * scale,
        panel.w - 20.0f * scale,
        panel.h - 20.0f * scale
    };
    rwrap->setDrawColor(SDL_Color{24, 45, 84, 255});
    rwrap->drawRectF(&insetFrame);

    const float contentPad = 44.0f * scale;
    float textX = panel.x + contentPad;
    float contentWidth = panel.w - contentPad * 2.0f;
    float cursorY = panel.y + contentPad * 0.6f;

    const char* title = "EXIT GAME?";
    const float titleScale = 2.0f * scale;
    int titleW = 0, titleH = 0;
    pixelFont->measure(title, titleScale, titleW, titleH);
    pixelFont->draw(renderer, textX, cursorY, title, titleScale, SDL_Color{255, 224, 130, 255});
    cursorY += titleH + 18.0f * scale;

    rwrap->setDrawColor(SDL_Color{32, 64, 110, 210});
    SDL_FRect divider{textX, cursorY, contentWidth, 2.0f * scale};
    rwrap->fillRectF(&divider);
    cursorY += 26.0f * scale;

    const std::array<const char*, 2> lines{
        "Are you sure you want to quit?",
        "Current progress will be lost."
    };
    const float bodyScale = 1.05f * scale;
    for (const char* line : lines) {
        int lineW = 0, lineH = 0;
        pixelFont->measure(line, bodyScale, lineW, lineH);
        pixelFont->draw(renderer, textX, cursorY, line, bodyScale, SDL_Color{210, 226, 245, 255});
        cursorY += lineH + 10.0f * scale;
    }

    const char* tip = "Enter confirms • Esc returns";
    const float tipScale = 0.9f * scale;
    int tipW = 0, tipH = 0;
    pixelFont->measure(tip, tipScale, tipW, tipH);

    const float buttonGap = 32.0f * scale;
    const float buttonH = 78.0f * scale;
    const float buttonW = (contentWidth - buttonGap) * 0.5f;
    float buttonY = panel.y + panel.h - contentPad - buttonH;

    float tipX = panel.x + (panel.w - tipW) * 0.5f;
    float tipY = buttonY - tipH - 14.0f * scale;
    pixelFont->draw(renderer, tipX, tipY, tip, tipScale, SDL_Color{150, 170, 205, 255});

    auto drawButton = [&](int idx, float btnX, SDL_Color baseColor, const char* label) {
        bool selected = (selectedButton == idx);
        SDL_FRect btn{btnX, buttonY, buttonW, buttonH};
        SDL_Color body = baseColor;
        if (selected) {
            body.r = Uint8(std::min(255, body.r + 35));
            body.g = Uint8(std::min(255, body.g + 35));
            body.b = Uint8(std::min(255, body.b + 35));
        }
        SDL_Color border = selected ? SDL_Color{255, 225, 150, 255} : SDL_Color{90, 120, 170, 255};
        SDL_Color topEdge = SDL_Color{Uint8(std::min(255, body.r + 20)), Uint8(std::min(255, body.g + 20)), Uint8(std::min(255, body.b + 20)), 255};

        rwrap->setDrawColor(SDL_Color{0, 0, 0, 110});
        SDL_FRect btnShadow{btn.x + 6.0f * scale, btn.y + 8.0f * scale, btn.w, btn.h};
        rwrap->fillRectF(&btnShadow);

        rwrap->setDrawColor(body);
        rwrap->fillRectF(&btn);

        SDL_FRect topStrip{btn.x, btn.y, btn.w, 6.0f * scale};
        rwrap->setDrawColor(topEdge);
        rwrap->fillRectF(&topStrip);

        rwrap->setDrawColor(border);
        rwrap->drawRectF(&btn);

        if (selected) {
            rwrap->setDrawColor(SDL_Color{255, 230, 160, 90});
            SDL_FRect glow{
                btn.x - 6.0f * scale,
                btn.y - 6.0f * scale,
                btn.w + 12.0f * scale,
                btn.h + 12.0f * scale
            };
            rwrap->drawRectF(&glow);
        }

        const float labelScale = 1.35f * scale;
        int labelW = 0, labelH = 0;
        pixelFont->measure(label, labelScale, labelW, labelH);
        float textX = btn.x + (btn.w - labelW) * 0.5f;
        float textY = btn.y + (btn.h - labelH) * 0.5f;
        SDL_Color textColor = selected ? SDL_Color{255, 255, 255, 255} : SDL_Color{235, 238, 250, 255};
        pixelFont->draw(renderer, textX, textY, label, labelScale, textColor);
    };

    float yesX = textX;
    float noX = yesX + buttonW + buttonGap;
    drawButton(0, yesX, SDL_Color{190, 70, 70, 255}, "YES");
    drawButton(1, noX, SDL_Color{70, 115, 190, 255}, "NO");

    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
    SDL_SetRenderViewport(renderer, &oldViewport);
    SDL_SetRenderScale(renderer, oldScaleX, oldScaleY);
}

void GameRenderer::renderPauseOverlay(
    SDL_Renderer* renderer,
    FontAtlas* pixelFont,
    float winW,
    float winH,
    float logicalScale
) {
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);

    // Switch to window coordinates for the full-screen overlay and text
    SDL_Rect oldViewport;
    SDL_GetRenderViewport(renderer, &oldViewport);
    float oldScaleX, oldScaleY;
    SDL_GetRenderScale(renderer, &oldScaleX, &oldScaleY);

    SDL_SetRenderViewport(renderer, nullptr);
    SDL_SetRenderScale(renderer, 1.0f, 1.0f);

    // Draw full screen overlay (darken)
    auto rwrap = renderer::MakeSDLRenderer(renderer);
    rwrap->setDrawColor(SDL_Color{0, 0, 0, 180});
    SDL_FRect pauseOverlay{0, 0, winW, winH};
    rwrap->fillRectF(&pauseOverlay);
    
    // Draw centered text
    const char* pausedText = "PAUSED";
    float pausedScale = 2.0f * logicalScale;
    int pW = 0, pH = 0;
    pixelFont->measure(pausedText, pausedScale, pW, pH);
    pixelFont->draw(renderer, (winW - pW) * 0.5f, (winH - pH) * 0.5f - (20 * logicalScale), pausedText, pausedScale, {255, 255, 255, 255});
    
    const char* resumeText = "Press P to resume";
    float resumeScale = 0.8f * logicalScale;
    int rW = 0, rH = 0;
    pixelFont->measure(resumeText, resumeScale, rW, rH);
    pixelFont->draw(renderer, (winW - rW) * 0.5f, (winH - pH) * 0.5f + (40 * logicalScale), resumeText, resumeScale, {200, 200, 220, 255});

    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);

    // Restore previous render state
    SDL_SetRenderViewport(renderer, &oldViewport);
    SDL_SetRenderScale(renderer, oldScaleX, oldScaleY);
}
