Skip to content

Graphics Guidelines - PixelRoot32 ​

Patterns and best practices for rendering, sprites, tilemaps, and palette systems.

Pipeline first: read Rendering for Renderer, layers, camera, and draw flow. This page focuses on asset layout, bitmap conventions, and tile/palette usage.


🎨 Sprite Guidelines ​

1bpp Sprite Definition ​

Define sprites as static const uint16_t arrays, one row per element:

cpp
// βœ… 1bpp sprite: bit 0 = leftmost, bit (width-1) = rightmost
static const uint16_t playerBitmap[] = {
    0b0000001111000000,  // Row 0
    0b0000011111100000,  // Row 1
    0b0000111111110000,  // Row 2
    0b0000111111110000,  // Row 3
    0b0000010101000000,  // Row 4
};

pixelroot32::graphics::Sprite playerSprite = {
    .width = 16,
    .height = 5,
    .data = playerBitmap
};

Sprite Descriptors ​

Always wrap bitmaps in descriptors:

cpp
// βœ… Descriptor approach
void MyActor::draw(Renderer& r) {
    r.drawSprite(playerSprite, static_cast<int>(x), static_cast<int>(y));
}

// ❌ Never pass raw bitmaps
void MyActor::draw(Renderer& r) {
    r.drawBitmap(playerBitmap, ...);  // Wrong API
}

Layered Sprites (Multi-Color) ​

Compose from multiple 1bpp layers:

cpp
// βœ… Layered multi-color sprite
static const uint16_t shipOutline[] = { ... };
static const uint16_t shipCockpit[] = { ... };
static const uint16_t shipEngine[] = { ... };

pixelroot32::graphics::MultiSprite ship = {
    .layers = {
        {shipOutline, PaletteType::PR32},
        {shipCockpit, PaletteType::NES},
        {shipEngine, PaletteType::GB}
    },
    .layerCount = 3
};

renderer.drawMultiSprite(ship, x, y);

Keep layer data static const for flash storage.

Higher Bit Depth (Optional) ​

Enable for specific use cases:

cpp
// platformio.ini
-DPIXELROOT32_ENABLE_2BPP_SPRITES=1   // 2x memory
-DPIXELROOT32_ENABLE_4BPP_SPRITES=1   // 4x memory
FormatUse ForCost
1bpp (default)Gameplay sprites, tiles1x
2bppLogos, detailed UI2x
4bppPhotos, title screens4x

Default to 1bpp for gameplay-critical assets.


πŸ—ΊοΈ Tilemap System ​

Basic Tilemap ​

cpp
// Tile indices (compact uint8_t)
static const uint8_t tileIndices[] = {
    0, 0, 0, 0,  // Row 0
    0, 1, 1, 0,  // Row 1 (tile 1 = ground)
    0, 1, 1, 0,  // Row 2
    0, 0, 0, 0,  // Row 3
};

pixelroot32::graphics::TileMap level = {
    .width = 4,
    .height = 4,
    .tileWidth = 16,
    .tileHeight = 16,
    .tiles = tileIndices,
    .tileset = groundTiles,
    .tilesetSize = 2  // 2 unique tiles
};

// Draw
renderer.drawTileMap(level, 0, 0);  // x, y offset

Tile Reuse ​

Reuse tiles across the map to minimize flash:

cpp
// βœ… Reuse: indices point to same tile data
// Tile 0 = sky (used 100 times)
// Tile 1 = ground (used 20 times)
// Total unique tiles: 2, not 120

Scrolling with Camera ​

cpp
// βœ… Centralized camera logic
class GameScene : public Scene {
    Camera2D camera;
    
public:
    void update(unsigned long dt) {
        camera.setTarget(player->position);
        camera.update(dt);
    }
    
    void draw(Renderer& r) {
        r.setDisplayOffset(-camera.getX(), -camera.getY());
        r.drawTileMap(backgroundLayer, 0, 0);
        Scene::draw(r);  // Actors
    }
};

Camera bounds under a non-orthogonal projection ​

Camera2D itself is projection-agnostic β€” it is a pure translation pushed into the renderer as an offset, and nothing in it assumes an axis-aligned tile grid. But setBounds() / setVerticalBounds() clamp in whatever units you hand them, and the camera moves in screen space.

For an orthogonal map, world units and screen units coincide, so passing the map's world extent is correct. Under a non-identity projection (gameplay::ProjectionSpec β€” isometric, oblique) they do not coincide: a cols Γ— rows isometric map is a diamond on screen, roughly (cols + rows) Γ— halfTileWidth wide, and its leftmost point is at cell (0, rows), not at cell (0, 0).

Passing cell-space or world-space bounds there stops the camera in the wrong place β€” usually visible as the view refusing to reach the left and right tips of the diamond. Clamp with projected bounds instead:

cpp
// βœ… Bounds computed through the projection, over all four map corners.
using namespace pixelroot32::gameplay;

inline constexpr ProjectionSpec kIso{0, 0, 16, 8, -16, 8};
static_assert(projectionSpecIsValid(kIso, kCols, kRows), "kIso out of range");

const int xs[4] = { cellToScreenX(0, 0, kIso),     cellToScreenX(kCols, 0, kIso),
                    cellToScreenX(0, kRows, kIso), cellToScreenX(kCols, kRows, kIso) };
// minX/maxX over xs[], likewise for Y, then:
camera.setBounds(math::toScalar(minX), math::toScalar(maxX - viewportWidth));

All four corners matter for the same reason projectionSpecIsValid() checks four: with a negative axis component the extreme is not at the corner an axis-aligned map would put it.


🎬 Tile Animation ​

Memory Budget ​

Tileset SizeRAM Usage% ESP32 DRAM
64 tiles73 bytes0.02%
128 tiles137 bytes0.04%
256 tiles265 bytes0.08%

Start with 64-128 tiles.

Initialization ​

cpp
// In PROGMEM
PIXELROOT32_SCENE_FLASH_ATTR const TileAnimation animations[] = {
    { 2, 4, 8, 0 },  // Water: tiles 2-5, 4 frames, 8Γ— (1/60 s) ticks per cell
    { 6, 2, 6, 0 },  // Lava: tiles 6-7, 2 frames, 6Γ— (1/60 s) ticks per cell
};

TileAnimationManager animManager(animations, 2, 64);

// Link to tilemap
TileMap2bpp backgroundLayer = {
    // ... other fields ...
    .animManager = &animManager  // Enables animations
};

Game Loop Integration ​

cpp
void MyScene::update(unsigned long dt) {
    animManager.step(dt);  // Wall-time pacing (see TileAnimationManager API)
    Scene::update(dt);
}

void MyScene::draw(Renderer& r) {
    r.drawTileMap(backgroundLayer, 0, 0);
    Scene::draw(r);
}

Speed Control ​

Animation speed is driven by frameDuration in TileAnimation data (larger value β†’ each sprite frame held longer). Do not gate step(dt) on engine loop count to change speed; that breaks wall-clock pacing when the loop runs faster than the display.

cpp
// Pause when game paused (do not call step while frozen)
if (!isPaused) {
    animManager.step(dt);
}

Common Pitfalls ​

  1. Sequential frames only: Tiles 2,3,4,5 - not 2,5,9,12
  2. Shared state: All instances of a tile share the same frame
  3. StaticTilemapLayerCache: If tilemap is in static group, advancing tile animation requires invalidate() on that cache when applicable

🎨 Multi-Palette Systems ​

Slot-Based Palettes ​

Separate palettes for sprites and backgrounds:

cpp
// Initialize during scene init
void MyScene::init() {
    pixelroot32::graphics::Color::enableDualPaletteMode(true);
    
    // Background slots (for tilemaps)
    pixelroot32::graphics::initBackgroundPaletteSlots();
    setBackgroundPaletteSlot(0, PaletteType::PR32);  // Ground
    setBackgroundPaletteSlot(1, PaletteType::NES);   // Water
    setBackgroundPaletteSlot(2, PaletteType::GB);    // Underground
    
    // Sprite slots
    pixelroot32::graphics::initSpritePaletteSlots();
    setSpritePaletteSlot(0, PaletteType::PR32);  // Player
    setSpritePaletteSlot(1, PaletteType::NES);   // Fire enemies
    setSpritePaletteSlot(2, PaletteType::GBC);   // Ice enemies
}

Custom Palettes ​

cpp
// In PROGMEM
static const uint16_t CUSTOM_FIRE[] = {
    0x0000, 0xFFFF, 0xF800, 0xFC00,  // Colors 0-3
    0xFA00, 0xF800, 0xF600, 0xF400,  // Colors 4-7
    // ... 16 colors total
};

// Apply to slot
setSpriteCustomPaletteSlot(5, CUSTOM_FIRE);

Batching with Context ​

cpp
// βœ… Set context once for many sprites
void BulletManager::drawAll(Renderer& r) {
    r.setSpritePaletteSlotContext(1);  // Fire palette
    
    for (auto& bullet : bullets) {
        r.drawSprite(bulletSprite, bullet.x, bullet.y);
    }
    
    r.setSpritePaletteSlotContext(0xFF);  // Reset
}

Slot Documentation ​

cpp
// Background slots:
// 0: Default ground (PR32)
// 1: Water areas (NES - blue)
// 2: Underground (GB - green)
// 3: Lava (custom red)
// 4-7: Reserved

// Sprite slots:
// 0: Player (PR32)
// 1: Fire enemies (NES)
// 2: Ice enemies (GBC)
// 3: Boss (PICO8)
// 4-7: Reserved

🧱 Render Layers ​

Standard layer assignment:

LayerContent
0Background (tilemaps, fills)
1Gameplay (player, enemies, bullets)
2UI (HUD, menus, text)
cpp
class MyActor : public Actor {
public:
    MyActor() {
        renderLayer = 1;  // Gameplay layer
    }
};

class HUD : public Actor {
public:
    HUD() {
        renderLayer = 2;  // UI layer (top)
    }
};

DocumentTopic
UI GuidelinesLayouts, panels, HUDs
PerformanceHot paths, optimization
Tile animationAnimation system deep dive
API GraphicsComplete graphics API

Graphics should be efficient, authentic, and layer-friendly.

Released under the MIT License.