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.

What's on the bench
- Battery
- ESP32-C3
- Joystick
- Resistor
- TFT 1.8″ 160×128 (ST7735)
How it's wired
- Battery · pos→ESP32-C3 · vin
- Battery · neg→ESP32-C3 · gnd
- ESP32-C3 · 3v3→TFT 1.8″ 160×128 (ST7735) · vcc
- ESP32-C3 · gnd2→TFT 1.8″ 160×128 (ST7735) · gnd
- ESP32-C3 · g4→TFT 1.8″ 160×128 (ST7735) · din
- ESP32-C3 · g6→TFT 1.8″ 160×128 (ST7735) · clk
- ESP32-C3 · g7→TFT 1.8″ 160×128 (ST7735) · cs
- ESP32-C3 · g3→TFT 1.8″ 160×128 (ST7735) · dc
- ESP32-C3 · g10→TFT 1.8″ 160×128 (ST7735) · rst
- ESP32-C3 · 3v3→Joystick · vcc
- ESP32-C3 · gnd2→Joystick · gnd
- ESP32-C3 · g1→Joystick · vry
- ESP32-C3 · g2→Joystick · sw
- ESP32-C3 · 3v3→Resistor · a
- Resistor · b→Joystick · 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)



