Breadboard
docs
Example project · Builds & projects

Pong (TFT + joystick)

A complete, genuinely playable Pong on the 1.8″ TFT: move the joystick to steer your paddle, press its button to serve. Real MicroPython runs the whole game — ball physics with paddle-face deflection, a beatable CPU opponent, live score, win screen and rematch — and streams only dirty rects over SoftSPI (~1 KB a frame, not the 40 KB framebuffer), which is what makes a game loop possible on a serial panel. First to 5. Select the joystick and drag its Y slider to play.

The Pong (TFT + joystick) circuit as rendered by the simulator

What's on the bench

  • Battery
  • ESP32-C3
  • Joystick
  • Resistor
  • TFT 1.8″ 160×128 (ST7735)

How it's wired

  • Battery · posESP32-C3 · vin
  • Battery · negESP32-C3 · gnd
  • ESP32-C3 · 3v3TFT 1.8″ 160×128 (ST7735) · vcc
  • ESP32-C3 · gnd2TFT 1.8″ 160×128 (ST7735) · gnd
  • ESP32-C3 · g4TFT 1.8″ 160×128 (ST7735) · din
  • ESP32-C3 · g6TFT 1.8″ 160×128 (ST7735) · clk
  • ESP32-C3 · g7TFT 1.8″ 160×128 (ST7735) · cs
  • ESP32-C3 · g3TFT 1.8″ 160×128 (ST7735) · dc
  • ESP32-C3 · g10TFT 1.8″ 160×128 (ST7735) · rst
  • ESP32-C3 · 3v3Joystick · vcc
  • ESP32-C3 · gnd2Joystick · gnd
  • ESP32-C3 · g1Joystick · vry
  • ESP32-C3 · g2Joystick · sw
  • ESP32-C3 · 3v3Resistor · a
  • Resistor · bJoystick · sw

The code

This MicroPython script runs on the emulated board every boot; edit it in the Code tab.

# PONG - move the joystick to steer the left paddle; first to 5 wins.
# Tuned hard for the emulated SoftSPI budget (~5 KB/s): the full 40 KB
# framebuffer is streamed exactly ONCE (boot). After that every repaint
# is a minimal dirty rect - paddles repaint only the rows they gained
# and lost (the body itself never blinks), the ball draws its new cell
# before erasing the old one, points erase a couple of rects instead of
# clearing the screen, and only net dashes actually painted over get
# redrawn. A typical frame is a few hundred bytes of SPI traffic, which
# is what makes a game loop possible on a bit-banged serial panel.
from machine import Pin, SoftSPI, ADC
import framebuf, time

W, H = 160, 128
TOP = 14                    # scoreboard strip; playfield is below
spi = SoftSPI(baudrate=8000000, sck=Pin(6), mosi=Pin(4), miso=Pin(5))
dc = Pin(3, Pin.OUT); cs = Pin(7, Pin.OUT, value=1); rst = Pin(10, Pin.OUT)
joy = ADC(Pin(1)); joy.atten(ADC.ATTN_11DB)
btn = Pin(2, Pin.IN)

def wr(c, d=None):
    cs(0); dc(0); spi.write(bytes([c]))
    if d is not None:
        dc(1); spi.write(bytes(d))
    cs(1)

rst(0); time.sleep_ms(20); rst(1); time.sleep_ms(50)
wr(0x01); time.sleep_ms(120)   # SWRESET
wr(0x11); time.sleep_ms(120)   # SLPOUT
wr(0x3A, [0x05])                # COLMOD 16-bit RGB565
wr(0x36, [0x00])                # MADCTL: 160x128 window
wr(0x29)                        # DISPON

def rgb(r, g, b):
    # byte-swapped RGB565 (the panel wire is big-endian, framebuf little).
    v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
    return ((v & 0xFF) << 8) | (v >> 8)

INK = rgb(240, 240, 240)
DIM = rgb(90, 100, 120)
BALL_C = rgb(120, 255, 150)

def blit(x, y, w, h, buf):
    wr(0x2A, [0, x, 0, x + w - 1])
    wr(0x2B, [0, y, 0, y + h - 1])
    cs(0); dc(0); spi.write(bytes([0x2C])); dc(1); spi.write(buf); cs(1)

def solid(w, h, color):
    b = bytearray(w * h * 2)
    if color:
        hi = color & 0xFF; lo = color >> 8
        for i in range(0, len(b), 2):
            b[i] = hi; b[i + 1] = lo
    return b

PW, PH, BS = 4, 24, 5
ball_spr = solid(BS, BS, BALL_C)
ball_gap = solid(BS, BS, 0)
pad_white = solid(PW, PH, INK)
pad_black = solid(PW, PH, 0)
dash_spr = solid(2, 5, DIM)
BALU = bytearray(9 * 9 * 2)     # ball union scratch (overlapping moves)
sb = bytearray(64 * 10 * 2)
sfb = framebuf.FrameBuffer(sb, 64, 10, framebuf.RGB565)

def net():
    for y in range(TOP + 2, H - 6, 14):
        blit(79, y, 2, 5, dash_spr)

def net_repair(x, y, w, h):
    # Redraw only the dashes a repaint actually painted over.
    if x + w <= 79 or x >= 81:
        return
    for dy in range(TOP + 2, H - 6, 14):
        if dy < y + h and dy + 5 > y:
            blit(79, dy, 2, 5, dash_spr)

def score_draw(a, b):
    sfb.fill(0)
    sfb.text('%d - %d' % (a, b), 8, 1, INK)
    blit(48, 2, 64, 10, sb)

last_msg = None
def msg(text, color):
    global last_msg
    mw = len(text) * 8 + 4
    mb = bytearray(mw * 12 * 2)
    mfb = framebuf.FrameBuffer(mb, mw, 12, framebuf.RGB565)
    mfb.fill(0)
    mfb.text(text, 2, 2, color)
    blit((W - mw) // 2, 60, mw, 12, mb)
    last_msg = ((W - mw) // 2, 60, mw, 12)

def msg_clear():
    global last_msg
    if last_msg:
        x, y, mw, mh = last_msg
        blit(x, y, mw, mh, solid(mw, mh, 0))
        net_repair(x, y, mw, mh)
        last_msg = None

def pad_move(x, old, new):
    # Repaint only the delta rows: the paddle body never leaves the
    # screen, so this cannot flicker, and a small move costs ~100 bytes.
    d = new - old
    if d == 0:
        return
    if d >= PH or -d >= PH:
        blit(x, new, PW, PH, pad_white)
        blit(x, old, PW, PH, pad_black)
        return
    if d > 0:
        blit(x, old + PH, PW, d, memoryview(pad_white)[: PW * d * 2])
        blit(x, old, PW, d, memoryview(pad_black)[: PW * d * 2])
    else:
        blit(x, new, PW, -d, memoryview(pad_white)[: PW * -d * 2])
        blit(x, new + PH, PW, -d, memoryview(pad_black)[: PW * -d * 2])

def ball_move(ox, oy, nx, ny):
    if ox >= 0 and abs(nx - ox) < BS and abs(ny - oy) < BS:
        # Overlapping cells: one atomic union blit.
        x0 = min(ox, nx); y0 = min(oy, ny)
        w = abs(nx - ox) + BS; h = abs(ny - oy) + BS
        mv = memoryview(BALU)[: w * h * 2]
        fbb = framebuf.FrameBuffer(mv, w, h, framebuf.RGB565)
        fbb.fill(0)
        fbb.fill_rect(nx - x0, ny - y0, BS, BS, BALL_C)
        blit(x0, y0, w, h, mv)
        net_repair(x0, y0, w, h)
        return
    blit(nx, ny, BS, BS, ball_spr)       # draw first: the ball never vanishes
    if ox >= 0:
        blit(ox, oy, BS, BS, ball_gap)
        net_repair(ox, oy, BS, BS)

def ball_erase(ox, oy):
    if ox >= 0:
        blit(ox, oy, BS, BS, ball_gap)
        net_repair(ox, oy, BS, BS)

# --- game state: positions in px, ball fixed-point x16, speeds in px/s ---
py = cy = (TOP + H - PH) // 2
score_p = score_c = 0
serves = 0
WIN_AT = 5

def serve(direction):
    global bx, by, vx, vy, speed, serves
    serves += 1
    bx = (W // 2 - BS // 2) * 16
    by = ((TOP + H) // 2) * 16
    speed = 70
    vx = speed * direction
    vy = ((serves * 23) % 91) - 45   # px/s, varies rally to rally
    if -10 < vy < 10:
        vy = 20

print('PONG ready - joystick = paddle, press its button to serve')
# The ONE full-frame stream: real panels power up with random RAM, so a
# real driver always clears once. Everything after this is dirty rects.
row = solid(W, 8, 0)
for y in range(0, H, 8):
    blit(0, y, W, 8, row)
net()
score_draw(0, 0)
msg('PRESS BUTTON', INK)
while btn.value() == 1:
    time.sleep_ms(30)
msg_clear()
serve(1)
state = 'play'
print('serve!')
opy, ocy = py, cy
obx, oby = -1, -1
pad_move(6, py - PH, py)
pad_move(150, cy - PH, cy)
last = time.ticks_ms()

while True:
    if state == 'over':
        if btn.value() == 0:
            score_p = score_c = 0
            msg_clear()
            ball_erase(obx, oby)
            obx = oby = -1
            score_draw(0, 0)
            serve(1)
            state = 'play'
            last = time.ticks_ms()
            print('rematch!')
        time.sleep_ms(50)
        continue

    now = time.ticks_ms()
    dt = time.ticks_diff(now, last)
    last = now
    if dt > 250: dt = 250

    # joystick Y -> paddle target, rate-limited to 120 px/s
    jv = joy.read_u16()
    target = TOP + ((65535 - jv) * (H - TOP - PH)) // 65535
    lim = 120 * dt // 1000
    dpy = target - py
    if dpy > lim: dpy = lim
    if dpy < -lim: dpy = -lim
    py += dpy

    # CPU: capped tracking - slower when the ball is moving away
    want = by // 16 - PH // 2
    if want < TOP: want = TOP
    if want > H - PH: want = H - PH
    climit = (60 if vx > 0 else 45) * dt // 1000
    dcy = want - cy
    if dcy > climit: dcy = climit
    if dcy < -climit: dcy = -climit
    cy += dcy

    # ball physics (px/s scaled by dt)
    bx += vx * dt * 16 // 1000
    by += vy * dt * 16 // 1000
    bpx = bx // 16; bpy = by // 16
    if bpy <= TOP:
        by = TOP * 16; vy = -vy
    if bpy >= H - BS:
        by = (H - BS) * 16; vy = -vy
    if vx < 0 and 6 <= bpx <= 6 + PW and bpy + BS >= py and bpy <= py + PH:
        vy += (bpy + BS // 2 - (py + PH // 2)) * 3   # deflect off the face
        speed = speed + 15 if speed < 140 else speed
        vx = speed
    if vx > 0 and 150 - BS <= bpx <= 150 + PW and bpy + BS >= cy and bpy <= cy + PH:
        vy += (bpy + BS // 2 - (cy + PH // 2)) * 3
        speed = speed + 15 if speed < 140 else speed
        vx = -speed
    if vy > 100: vy = 100
    if vy < -100: vy = -100

    scored = 0
    if bpx < 0:
        score_c += 1; scored = -1
    elif bpx > W - BS:
        score_p += 1; scored = 1
    if scored:
        print('score  YOU %d - %d CPU' % (score_p, score_c))
        score_draw(score_p, score_c)
        # No screen clear between points - just take the dead ball off.
        ball_erase(obx, oby)
        obx = oby = -1
        if score_p >= WIN_AT or score_c >= WIN_AT:
            won = score_p >= WIN_AT
            msg('YOU WIN!' if won else 'CPU WINS', BALL_C if won else rgb(255, 120, 120))
            print('game over -', 'you win!' if won else 'cpu wins', '- press the button for a rematch')
            state = 'over'
            continue
        serve(-scored)
        time.sleep_ms(250)
        last = time.ticks_ms()
        continue

    # dirty-rect repaints
    if opy != py:
        pad_move(6, opy, py)
        opy = py
    if ocy != cy:
        pad_move(150, ocy, cy)
        ocy = cy
    if obx != bpx or oby != bpy:
        ball_move(obx, oby, bpx, bpy)
        obx, oby = bpx, bpy
    time.sleep_ms(15)

Related projects