๐ŸŽฎ Game Development

A detailed guide for developing your own games on the TDisplayS3 Adventure engine.

Architecture Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Your game     โ”‚  <- implements the Game interface
โ”‚   (src/games/)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚ uses
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Engine Core   โ”‚  <- Game, Engine, Display, Input
โ”‚   (src/core/)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”‚ configured via
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Board Config   โ”‚  <- pins, display geometry, timing
โ”‚  (src/boards/)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Game Interface

Every game implements these 4 methods (src/core/game.h):

class Game {
public:
  virtual const char* name() const = 0;           // Game name for the menu
  virtual void init() = 0;                         // Once at start
  virtual void update(const InputState& in, uint32_t dtMs) = 0;  // Logic
  virtual void draw() = 0;                         // Render to gfx.tft()
  virtual void quit() {}                           // Optional: cleanup

  bool wantsMenu = false;  // true = back to the launcher
};

Lifecycle of a Game

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  init()  โ”‚ โ”€โ”€โ–บ โ”‚ update() โ”‚ โ”€โ”€โ–บ โ”‚  draw()  โ”‚ โ”€โ”€โ–บ โ”‚  loop    โ”‚
โ”‚  once    โ”‚     โ”‚  ~60 FPS โ”‚     โ”‚  ~60 FPS โ”‚     โ”‚  repeat  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                                                          โ”‚
                                                wantsMenu = true?
                                                          โ”‚
                                                     โ”Œโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”
                                                     โ–ผ         โ”‚
                                                   quit()   continue

Input System

InputState Structure (src/core/input.h)

struct InputState {
  bool     down[2];        // true while the button is held (Key::A=0, Key::B=1)
  bool     pressed[2];     // true only in the frame of the release (short)
  bool     longPressed[2]; // true once after a long hold
  uint32_t holdMs[2];      // hold time in ms (0 = not pressed)
};

Access in Your Game

void MyGame::update(const InputState& in, uint32_t dtMs) {
  // Short press (once on release)
  if (in.pressed[0]) { /* A short */ }
  if (in.pressed[1]) { /* B short */ }

  // Long press (once after LONG_X_MS)
  if (in.longPressed[0]) { /* A long (~1200ms) */ }
  if (in.longPressed[1]) { /* B long (~700ms) */ }

  // Continuously while held
  if (in.down[0]) { /* A held */ }

  // Hold time in milliseconds
  uint32_t holdDuration = in.holdMs[1];
}

Button Mapping (T-Display-S3)

Logical Physical Pin Active
Key::A BOOT GPIO0 LOW
Key::B free GPIO14 LOW

Display System

Access via gfx

#include "core/display.h"

// In draw():
auto& tft = gfx.tft();

// Display size
constexpr int16_t W = gfx.width();   // 170 (portrait)
constexpr int16_t H = gfx.height();  // 320 (portrait)

For animated games (like Neon Run) a sprite framebuffer is recommended:

#include <TFT_eSPI.h>

// Declare as a member (lands in PSRAM on the S3)
TFT_eSprite fb_{&gfx.tft()};

void MyGame::init() {
  // Create the framebuffer (full screen size)
  fb_.setColorDepth(16);  // RGB565
  fb_.createSprite(gfx.width(), gfx.height());
}

void MyGame::draw() {
  // Draw everything into the framebuffer
  fb_.fillScreen(TFT_BLACK);
  fb_.fillCircle(85, 160, 20, TFT_RED);

  // Push to the display once (flicker-free!)
  fb_.pushSprite(0, 0);
}

void MyGame::quit() {
  fb_.deleteSprite();  // free memory
}

Important TFT_eSPI Commands

Command Description Example
fillScreen(color) Clear screen tft.fillScreen(TFT_BLACK)
drawString(text, x, y, font) Draw text tft.drawString("HP: 3", 5, 5, 2)
setTextColor(fg, bg) Set text color tft.setTextColor(TFT_WHITE, TFT_BLACK)
setTextDatum(datum) Text alignment tft.setTextDatum(TC_DATUM) // top-center
fillRect(x,y,w,h,color) Filled rectangle tft.fillRect(10, 10, 50, 20, TFT_RED)
drawRect(x,y,w,h,color) Rectangle outline tft.drawRect(10, 10, 50, 20, TFT_WHITE)
fillCircle(cx,cy,r,color) Filled circle tft.fillCircle(85, 160, 20, TFT_BLUE)
drawCircle(cx,cy,r,color) Circle outline tft.drawCircle(85, 160, 20, TFT_WHITE)
drawLine(x1,y1,x2,y2,color) Draw line tft.drawLine(0, 0, 169, 319, TFT_GREEN)
fillTriangle(x1,y1,x2,y2,x3,y3,color) Filled triangle tft.fillTriangle(85,140, 65,180, 105,180, TFT_YELLOW)
drawPixel(x, y, color) Single pixel tft.drawPixel(85, 160, TFT_WHITE)
setRotation(r) Display rotation tft.setRotation(1) // 0-3
pushImage(x, y, w, h, data) Load bitmap tft.pushImage(0, 0, 32, 32, mySprite)

Fonts (1-8 available)

tft.drawString("Small",  10, 10, 1);  // Font 1: small (8px)
tft.drawString("Normal", 10, 30, 2);  // Font 2: normal (16px)
tft.drawString("Large",  10, 50, 4);  // Font 4: large (26px)
tft.drawString("Huge",   10, 80, 7);  // Font 7: huge (48px)
tft.drawString("Number", 10, 140, 8); // Font 8: digits only (75px)

Time & Frame Rate

Delta-Time (dtMs)

The engine calls update() with the time delta since the last frame:

void MyGame::update(const InputState& in, uint32_t dtMs) {
  // dtMs = milliseconds since the last frame (~16ms at 60 FPS)

  // Frame-rate-independent movement:
  float speed = 100.0f;  // pixels per second
  playerX += speed * (dtMs / 1000.0f);
}

Custom Timing

// In the class:
uint32_t lastActionMs = 0;
uint32_t actionCooldown = 500;  // 500ms cooldown

void MyGame::update(const InputState& in, uint32_t dtMs) {
  uint32_t now = millis();

  if (in.pressed[1] && now - lastActionMs > actionCooldown) {
    // do action
    lastActionMs = now;
  }
}

Random Number Generator

The engine initializes randomSeed() automatically:

// Dice roll W6 (1-6)
int die = random(1, 7);

// Random choice
if (random(100) < 25) {  // 25% chance
  // critical hit!
}

// Random position
int x = random(10, 160);

Memory Management (ESP32-S3)

Resource Size Note
Internal RAM ~512 KB Variables, stack
PSRAM 8 MB Large framebuffers, bitmaps
Flash 16 MB Program, PROGMEM data

PROGMEM for Constant Data

// Store bitmap in flash (saves RAM)
const uint16_t mySprite[] PROGMEM = {
  0xF800, 0xF800, 0x07E0, 0x07E0,  // ...
};

// Load from flash
tft.pushImage(x, y, 16, 16, mySprite);

PSRAM for Large Objects

// Framebuffers automatically land in PSRAM (with TFT_eSprite)
TFT_eSprite fb_{&gfx.tft()};
fb_.createSprite(170, 320);  // ~106 KB in PSRAM

Sprite Themes (Swappable Graphics)

Neon Run demonstrates the sprite theme system (src/games/neonrun/sprites.h):

// Implement your own theme:
class MyBitmapTheme : public SpriteRenderer {
public:
  const NeonPalette& palette() const override { return myPalette; }
  void drawPlayer(TFT_eSprite& fb, int16_t x, int16_t y, bool visible) override {
    // Draw your own bitmap instead of vector graphics
    fb.pushImage(x, y, 16, 16, myPlayerSprite);
  }
  // ... more methods
};

// Activate in init():
void MyGame::init() {
  gSprites = &myBitmapTheme;  // instead of neonVectorTheme
}

Best Practices

1. Do Not Block draw()

// โŒ Bad: heavy computation in draw()
void draw() {
  for (int i = 0; i < 10000; i++) { /* ... */ }  // Blocks!
  // draw...
}

// โœ… Good: compute in update(), only draw in draw()
void update(const InputState& in, uint32_t dtMs) {
  // logic here
}
void draw() {
  // draw only
}

2. Framebuffer for Animations

// โŒ Bad: draw directly to the display (flickers)
tft.fillScreen(TFT_BLACK);  // Flicker!
tft.fillCircle(x, y, r, TFT_RED);

// โœ… Good: framebuffer (flicker-free)
fb_.fillScreen(TFT_BLACK);
fb_.fillCircle(x, y, r, TFT_RED);
fb_.pushSprite(0, 0);  // push once

3. Constants for Magic Numbers

// โŒ Bad
class MyGame : public Game {
  float x = 85;  // What does 85 mean?
};

// โœ… Good
class MyGame : public Game {
  static constexpr int16_t CENTER_X = 85;
  static constexpr int16_t PLAYER_Y = 262;
  float x = CENTER_X;
};

4. State Machine for Game Phases

enum Phase : uint8_t { TITLE, PLAY, GAMEOVER, WIN };

Phase phase_ = TITLE;

void update(const InputState& in, uint32_t dtMs) {
  switch (phase_) {
    case TITLE:
      if (in.pressed[1]) phase_ = PLAY;
      break;
    case PLAY:
      // game logic
      if (gameOver) phase_ = GAMEOVER;
      break;
    case GAMEOVER:
      if (in.longPressed[1]) wantsMenu = true;
      break;
  }
}

Debugging

Serial Monitor

pio device monitor  # 115200 baud
// In your code:
Serial.printf("Score: %d, HP: %d, Phase: %d\n", score, hp, phase);
Serial.println(F("Debug: player collided"));  // F() saves RAM

Common Errors

Problem Cause Solution
Black screen Power-hold GPIO15 not HIGH Do not change Engine::begin()
Flickering graphics Drawing directly to the display Use a framebuffer (TFT_eSprite)
Buttons not responding Wrong pin or active HIGH Check board config (active LOW)
Build error TFT_eSPI Wrong version Pin to 2.5.43 (platformio.ini)
โ€œSketch too bigโ€ Partition too small Check partitions_16mb.csv

โ† Wiki Home ยท Docs Home