Esp32 s3 microcontroller web application controlled pong arcade game 3.5mb

  • embedded
  • esp32-s3
  • micropython
  • esp-idf
  • cplusplus
  • performance
  • network
  • english

posted on 14 Aug 2026 under category programming

Post Meta-Data

Date Language Author Description
14.08.2026 English Claus Prüfer (Chief Prüfer) An ESP32-S3 Microcontroller Web Application-Controlled PONG Arcade Game

An ESP32-S3 Microcontroller Web Application-Controlled PONG Arcade Game In Just 3.5 MB

EmojiCodeEmojiCodeEmojiCode

Introduction

A full web-controlled PONG arcade game running on an ESP32-S3 in roughly 3.5 MB is not a gimmick. It is a concrete engineering result that demonstrates how far disciplined architecture can push constrained hardware.

This article contrasts that microcontroller efficiency with typical waste patterns seen in oversized x86_64 server implementations. The key point is simple: performance is not purchased only with bigger CPUs. It is engineered through architecture, memory discipline, and choosing the right mechanism at the right layer.

Amazing Result

All the techniques described in this article deliver outstanding performance on a highly constrained RISC CPU. As a result, the device-hosted web control application (x0 app) loads via encrypted Wi-Fi on multiple connected mobile devices in less than 1.5 seconds, including minimal Bootstrap CSS and FontAwesome icons. Furthermore, the control mechanism guarantees real-time responsiveness; using the paddle control feels identical to using a hardwired controller.

GitHub Repository

GitHub project repository: https://github.com/WEBcodeX1/micropython-as.

Core Architecture

  • WPA2 Wi-Fi Access Point / DHCP server
  • lwIP IPv4 networking stack over encrypted Wi-Fi
  • Minimal DNS server with EDNS0 and A-record lookup
  • Task-triggered RGB LED pulse control
  • Multi-core CPU utilization via C++ POSIX threads
  • Embedded MicroPython runtime in the main context
  • Real-time MicroPython Pong rendering on an SSD1306 OLED
  • Browser-based real-time game paddle control / enhanced web app
  • Compile-time generated FlashROM filesystem
  • HTTP/1.1 static web server running in a dedicated RTOS task
  • Performance-optimized HTTP parser written in C++23
  • Player-versus-Player (PvP) and Player-versus-AI (PvAI) gameplay modes
  • HTTP/1.1 JSON application API as the MicroPython control interface

Even with frontend assets included, the final monolithic firmware remains near 3.5 MB.

EmojiBulb Size Reality

The 3.5 MB footprint already includes RTOS, bootloader, flashing routines, debugging infrastructure, and IRQ/backtrace contexts.

How Is That Size Possible?

  1. Framework-level memory optimization with C++ and ESP-IDF cross-compilation
  2. Targeted architecture design aligned with ESP-IDF subsystem strengths
  3. Static object precalculation across C++ and MicroPython boundaries

On startup, the static web server delivers around 66 files and the browser client becomes interactive in approximately 1.5 seconds. Under concurrent load (including a rotating 12-line vector cube on the title screen), minor transient stutter appears, but overall responsiveness remains excellent for this class of device.

The C++ / MicroPython control bridge is also explicit in the source. HTTP endpoints are mapped to compact internal request IDs in ASRequestDef.hpp and ASRequestHandler.cpp:

  • /python/startgame
  • /python/paddleup
  • /python/paddledown

The first block is the HTTP ingress stage: route match, payload copy, and state transition.

// src/components/network_oop/ASRequestHandler.cpp (excerpt)
if (ASRequestDef.URL == Request.URL && ASRequestDef.HTTPMethod == Request.HTTPMethod) {
    ASRequestID = ASRequestDef.ID;
    ASRequestContentLength = Request.Payload.length();
    Request.Payload.copy(ASRequestExchangeBuffer, ASRequestContentLength);
    ASRequestStatus = AS_REQ_PROCESSING;
}

The second block is the interpreter dispatch stage in the main loop: consume the request ID, call the matching function, and acknowledge completion.

// src/main/micropython_as.cpp (excerpt)
if (ASRequestStatus == AS_REQ_PROCESSING) {
    if (ASRequestID == AS_REQ_GAME_START && GameRunning == false) { ... }
    if ((ASRequestID == AS_REQ_PADDLE_UP || ASRequestID == AS_REQ_PADDLE_DOWN) && GameRunning == true) {
        ResultStatus = interpreter.callFunctionCBuffer(
            MPFunctionGetPlayer, &ASRequestExchangeBuffer[0], ResultString
        );
    }
    ASRequestStatus = AS_REQ_PROCESSED;
}

For an IT architecture discussion, this matters: endpoint parsing and transport buffering are intentionally handled in C++, and only compact control payloads cross into the interpreter. That is a major reason why real-time responsiveness remains stable despite constrained SRAM and CPU budgets.

EmojiWarning MicroPython Boundary Optimization

Offloading networking and web-service abstraction from the interpreter into dedicated C++ tasks transforms practical MicroPython throughput and enables real-time tasks that would not be feasible if all networking stayed inside the interpreter loop.

EmojiWarning Language Integration Caution

On ESP-class targets, replacing the optimized ESP-IDF/C++ structure with alternative language stacks can easily degrade the tuned CMake and subsystem optimization path.

Implementation Basics

To make this less abstract, the task layout is visible directly in src/main/micropython_as.cpp. The application starts three dedicated pthread-backed FreeRTOS tasks (LED effects, DNS server, HTTP server) then loads the embedded MicroPython game module and enters the render/request loop:

// src/main/micropython_as.cpp (excerpt)
pthread_create(&LEDThread, NULL, led_flashing_thread, NULL);
pthread_detach(LEDThread);

pthread_create(&DNSServerThread, NULL, dns_server_thread, NULL);
pthread_detach(DNSServerThread);

esp_pthread_cfg_t esp_pthread_cfg = esp_pthread_get_default_config();
esp_pthread_cfg.pin_to_core = 1;

pthread_attr_setstacksize(&HTTPThreadAttributes, 16384);
pthread_create(&HTTPThread, &HTTPThreadAttributes, http_server_thread, NULL);
pthread_detach(HTTPThread);

*MicroPython* interpreter(&InterpreterHeap[0], MICROPYTHON_HEAP_SIZE, &InterpreterStackTop);
mp_embed_exec_str(pong_code1);
mp_embed_exec_str(pong_code2);

This is precisely the architecture claim in executable form: networking and protocol handling live in dedicated C++ tasks, while game logic remains scriptable through MicroPython.

That architectural statement is also visible in the subsystem boundaries themselves. The project is not “well designed” merely because it uses several technologies at once. It is well designed because each layer has a narrow responsibility, a small data contract, and a deliberately cheap execution path.

Static FlashROM Filesystem

One of the most important architectural choices is not even inside the network task itself, but in the filesystem component that feeds it.

The project does not use a runtime filesystem such as SPIFFS or LittleFS for the web frontend. Instead, src/components/filesystem/convert_static_fs.py converts all static assets into two generated headers:

// src/components/filesystem/Filesystem.hpp
#include "filedata.h"      // static const unsigned char fileN[...] = { ... };
#include "filemetadata.h"  // static const ServerFile fN = { ... };

At runtime, Filesystem::getFileMetadata() in src/components/filesystem/Filesystem.hpp simply walks that compile-time array and returns a ServerFile object with the exact source struct layout:

// src/components/filesystem/Filesystem.hpp
struct ServerFile {
    string ContentPath;
    string ContentType;
    const unsigned char* ContentPointer;
    unsigned int ContentLength;
};

The generated declarations are explicit and easy to verify in filemetadata.h:

static const ServerFile f1 = { "/index.html", "text/html", file1, 4801 };
static const ServerFile f14 = { "/userFunctions.js", "text/javascript", file14, 4753 };
static const ServerFile f63 = { "/text-data.json", "application/json", file63, 26778 };
static const std::array<ServerFile, 66> ServerFiles = { /* ... */ };

Zero Copy Semantics

This is the critical engineering point: the HTTP layer does not read files from a partition, does not allocate response buffers for asset payloads, and does not copy static file contents into temporary memory before sending them.

The interaction with the HTTP generator in ClientHandler.cpp is direct:

  1. Resolve the requested URL via Filesystem::getFileMetadata()
  2. Pass FileMetadata.ContentPointer and FileMetadata.ContentLength into MsgSetBodyRef()
  3. Let httpgenerator.cpp send header and body in two phases
  4. Advance only the body pointer / remaining-length metadata during partial writes

This approach is highly effective because httpgenerator.cpp stores only a body pointer and body length, and MsgUpdateSendMetadata() advances that pointer with pointer arithmetic after each write() call. In other words, the static file data stays in its original compiled form while the sender only moves a cursor over it. For an embedded web server, this is exactly the kind of zero-copy delivery path that turns a “small device” into a serious application server.

Runtime CPU Cycle Reduction

This architecture also eliminates an entire class of potential failures:

  • No mount timing
  • No file-open latency
  • No runtime path translation layer
  • No flash partition management for assets
  • No duplicate payload buffering

Application Server Layer

The second major architectural element is the application-server boundary implemented in src/components/network_oop together with src/main/micropython_as.cpp.

The system is intentionally split into two active execution contexts for the critical control path:

  • The dedicated HTTP server pthread / FreeRTOS task
  • The main loop hosting the embedded MicroPython interpreter and display rendering

Network Processing

  • Socket accept
  • Non-blocking reads
  • HTTP request parsing
  • Static-file GET delivery
  • Routing of application endpoints into compact internal request IDs
  • Sending JSON responses back to the browser

Main Loop Responsibility

  • Game-state transitions
  • MicroPython function execution
  • Display rendering
  • LED game-event signalling

The synchronization between both sides is intentionally minimalist. Instead of queues, dynamic objects, or heavyweight RPC abstractions, the software uses a tiny shared exchange surface defined in src/components/network_oop/ASRequestGlobal.hpp:

  • ASRequestStatus
  • ASRequestID
  • ASRequestContentLength
  • ASRequestExchangeBuffer[2048]

Those shared-state constants are intentionally tiny:

static constexpr unsigned int AS_REQ_WAIT_IN = 1;
static constexpr unsigned int AS_REQ_PROCESSING = 2;
static constexpr unsigned int AS_REQ_PROCESSED = 3;

The application server URL contract is equally explicit and declarative in ASRequestDef.hpp:

static const ASRequestDefinition_t r1 = { AS_REQ_GAME_START, "/python/startgame", HTTP_METHOD_GET,  "" };
static const ASRequestDefinition_t r2 = { AS_REQ_GAME_STOP,  "/python/stopgame",  HTTP_METHOD_GET,  "" };
static const ASRequestDefinition_t r3 = { AS_REQ_PADDLE_UP,  "/python/paddleup",  HTTP_METHOD_POST, "" };
static const ASRequestDefinition_t r4 = { AS_REQ_PADDLE_DOWN,"/python/paddledown",HTTP_METHOD_POST, "" };

The flow is straightforward:

  1. ASRequestHandler.cpp matches an HTTP request against the declarative route list in ASRequestDef.hpp
  2. The handler copies only the request payload bytes into the shared exchange buffer
  3. It sets the request ID and flips ASRequestStatus to AS_REQ_PROCESSING
  4. The main loop observes that state, performs the MicroPython call, and marks the request as processed
  5. ClientHandler.cpp sees AS_REQ_PROCESSED, wraps the shared buffer as JSON response body, sends it, and resets the state to AS_REQ_WAIT_IN

Smart, Simple Implementation

For an implementor, the programming model is intentionally low-friction because endpoint registration and runtime dispatch are plain C++ statements with no hidden framework layer:

// src/components/network_oop/ASRequestHandler.hpp
static constexpr unsigned int AS_REQ_PADDLE_UP = 3;

// src/components/network_oop/ASRequestDef.hpp
static const ASRequestDefinition_t r3 = {
    AS_REQ_PADDLE_UP,
    "/python/paddleup",
    HTTP_METHOD_POST,
    ""
};

// src/main/micropython_as.cpp
if ((ASRequestID == AS_REQ_PADDLE_UP || ASRequestID == AS_REQ_PADDLE_DOWN) && GameRunning == true) {
    ResultStatus = interpreter.callFunctionCBuffer(
        MPFunctionGetPlayer, &ASRequestExchangeBuffer[0], ResultString
    );
}
ASRequestStatus = AS_REQ_PROCESSED;

This mechanism represents a deliberately decoupled, low-overhead messaging architecture rather than a mere simplification. The browser-facing API ensures high extensibility, as integrating a new endpoint requires a concise, three-step sequence:

  1. Add static constexpr AS_REQ_* ID in ASRequestHandler.hpp
  2. Add static const ASRequestDefinition_t rN in ASRequestDef.hpp
  3. Add one ASRequestID branch in src/main/micropython_as.cpp

This architecture facilitates straightforward handling by assigning narrow responsibilities to each stage—routing, ID assignment, atomic request buffering, interpreter dispatch, and response flushing—thereby eliminating deep callbacks and complex RPC debugging. Consequently, the application-server component remains highly integrable and deterministic. The design ultimately bridges the gap between ease of extension and performance, ensuring the execution path from socket to game action remains compact enough to preserve responsiveness.

Efficient Thread Synchronization

On ESP32-C3 and ESP32-S3, aligned single-word 32-bit reads and writes are naturally atomic. For selected state flags, a primitive such as static unsigned int lockvar = 0; is sufficient for safe read/compare/assign task coordination.

This avoids unnecessary mutex or semaphore overhead for simple state transfer patterns. The rule is strict: no non-atomic read-modify-write sequences (for example lockvar++) without explicit synchronization.

In contrast, on x86_64 enterprise systems, std::atomic is intentionally used in shared-memory queue designs such as NLAP (Next Level Application Protocol), where cache coherency and hardware lock instructions are exploited for high-throughput user-space request distribution.

In the ESP32-S3 implementation, this low-overhead pattern is visible in the shared request and LED trigger state (src/components/network_oop/ASRequestGlobal.hpp, src/main/micropython_as.cpp):

// src/components/network_oop/ASRequestGlobal.hpp
extern unsigned int ASRequestStatus;
extern unsigned int ASRequestID;
extern unsigned int ASRequestContentLength;
extern char ASRequestExchangeBuffer[2048];
// src/main/micropython_as.cpp (excerpt)
static unsigned int LEDFlashTrigger = 0;
unsigned int ASRequestStatus = AS_REQ_WAIT_IN;

if (ASRequestStatus == AS_REQ_PROCESSING) {
    // read/compare/assign state transitions
    ...
    ASRequestStatus = AS_REQ_PROCESSED;
}

The article’s locking statement is therefore not theoretical. For this specific control-plane shape, the software uses compact shared-word state transitions instead of heavy synchronization primitives in the hottest parts of the loop.

Non-Blocking Berkley Sockets

The networking layer uses the lwIP Berkeley Sockets port provided by ESP-IDF, but runs it in a dedicated HTTP server RTOS task so socket processing is isolated from rendering and interpreter work.

In that server task, all relevant socket operations are non-blocking (accept, recv, and response writes), and TCP_NODELAY is enabled so control packets are transmitted immediately instead of waiting for coalescing. This keeps browser control latency low and predictable.

Performance-Critical Components

This is one of those places where modern C++ really pays off in practice. The key trio is std::string_view, std::span, and std::spanstream (std::ispanstream), and the origin is the HTTP/1.2 parser/generator implementation: http://github.com/WEBcodeX1/http-1.2

// WEBcodeX1/http-1.2/src/http/httpparser.cpp (representative excerpt)
std::span<const char> requestBytes{rawRequest.data(), rawRequest.size()};
std::ispanstream input(requestBytes);
std::string line;

while (std::getline(input, line)) {
    std::string_view headerLine{line};
    parseHeaderLine(headerLine);
}
// WEBcodeX1/http-1.2/src/http/httpgenerator.cpp (representative excerpt)
std::string_view bodyView{BodyPointer, BodyLength};
const auto chunk = bodyView.substr(bytesSent, bytesToSendNow);
::write(fd, chunk.data(), chunk.size());
bytesSent += chunk.size();

Short version of the performance gain: the parser reads directly from existing request memory, and the generator sends slices over existing body memory. Fewer temporary strings, fewer heap allocations, and less copy churn means lower latency per request.

SSD1306 Rendering Pipeline

The ESP32 OLED I2C driver design prioritizes simplicity. It eliminates the need for complex frame rate calculations and rendering logic by relying on a predictable, single-buffer update mechanism.

Key Architectural Advantages

  • No Complex Pacing Logic: Avoids adaptive refresh schedulers, frame-pacing subsystems, or FPS bookkeeping.
  • Stable Hardware Pacing: Every full-frame flush takes exactly 30 milliseconds over the 400 kHz I2C clock.
  • Deterministic Timing: The physical transfer volume is completely constant, creating a natural hardware-based frame rate.
  • Minimal Code Surface: Provides a highly simplified display abstraction layer for real-time applications.

Implementation Workflow

The system wraps the driver into a clean C++ interface (src/components/peripherals/Display.cpp) that handles the rendering pipeline in four straightforward steps:

  • Initialize: Sets up the I2C bus and display hardware once.
  • Draw Lines: Modifies geometric data inside the internal memory buffer.
  • Render Text: Writes string and character data directly to the buffer.
  • Flush Buffer: Triggers a single sequential transfer to update the physical screen.

Low-Level Driver Mechanics

The underlying driver (src/components/ssd1306) abstracts away memory complexity:

  • Buffer Ownership: Manages a single internal 8-page display buffer (SSD1306_t::_page[8]).
  • Memory-First Drawing: Functions like _ssd1306_pixel() and _ssd1306_line() modify this local memory immediately.
  • Sequential Transmission: ssd1306_show_buffer() sends the pages one by one via i2c_display_image(), keeping the codebase simple and straightforward.

MicroPython PONG Runtime

The game logic itself was AI engineered as a Python-oriented translation of the original C++ project Pong84: https://github.com/thewarrenjames/Pong84.

Following this translation step, the runtime architecture was engineered for deterministic embedded execution: utilizing static, preloaded objects, a compact control surface, and predictable frame-step invocation from the C++ layer.

Below is the simplified, representative MicroPython code excerpt:

# micropython/pong.py
BALL_DX = 1
BALL_DY = 1
PADDLE_MIN_Y = 0
PADDLE_MAX_Y = 48

def step_game(paddle_delta):
    global paddle_y, ball_x, ball_y, BALL_DX, BALL_DY
    paddle_y = min(PADDLE_MAX_Y, max(PADDLE_MIN_Y, paddle_y + paddle_delta))
    ball_x += BALL_DX
    ball_y += BALL_DY
    if ball_y <= 0 or ball_y >= 63:
        BALL_DY = -BALL_DY

Below is the corresponding C++ layer excerpt:

// src/main/micropython_as.cpp
if (GameRunning == true) {
    interpreter.callFunction(MPFunctionStepGame, ResultString);
    interpreter.callFunction(MPFunctionRenderFrame, ResultString);
}

This architecture maintains real-time performance because the core game loop uses a fixed frame rate, objects are preloaded rather than reallocated per frame, and the interpreter is restricted to game mathematics and rendering orchestration. Network I/O, socket management, and HTTP parsing operate within separate C++ tasks, preventing I/O jitter from blocking frame progression.

What Is Wrong With x86_64?

The core question is: how can a small microcontroller web application feel so immediate, while many web applications on 128-core, 5 GHz x86_64 systems still feel high-latency?

The answer is rarely raw hardware limits. The answer is usually implementation entropy.

x86_64 Linux environments provide enormous flexibility (blocking vs non-blocking sockets, TLS layering choices, event models, kernel/user interactions), and that flexibility is often used without strict architectural constraints. The result is avoidable latency, wasted CPU cycles, and inflated memory behavior.

The ESP32-S3 case demonstrates that constrained systems can outperform badly aligned large systems in practical throughput-per-resource terms.

At the same time, x86_64 can reach extreme performance when correctly tuned:

  • Huge Pages to reduce TLB pressure
  • User-space low-latency network architecture
  • Direct memory and offload-friendly designs
  • Tight control of data copies and system call boundaries

The principle is universal: use the right computational mechanism at the right layer.

The BIG Brother Optimization

If embedded projects are the small, precision-built sibling, then high-performance x86_64 systems are the “big brother” that must be trained to spend its resources responsibly. In practice, this means moving away from loosely structured legacy glue layers and toward deliberate, modern C++ architecture where ownership, data flow, and execution boundaries are explicit and measurable.

At the I/O layer, the most effective patterns combine io_uring / liburing with event-driven scheduling concepts familiar from epoll(). The objective is not to chase novelty, but to collapse avoidable context transitions, keep queue handling predictable, and preserve throughput under sustained concurrency. This has to be reinforced at compile time and at runtime: aggressive static evaluation (constexpr, preprocessor-guided specialization), preloaded runtime objects, and disciplined parsing via std::string_view all reduce waste that would otherwise accumulate as copy overhead and allocator churn. Together with move semantics (std::move) and zero-copy oriented transfer paths, these techniques turn raw CPU frequency into practical application throughput.

NLAP (Next Level Application Protocol) is relevant here because it applies the same optimization strategy to high-throughput application messaging: stable framing, explicit state handling, and predictable processing paths across long-lived transport sessions. A full protocol introduction and architecture discussion is available in the dedicated article and repository: https://github.com/WEBcodeX1/http-1.2

Within NLAP-style message-framed processing, the same design principle continues. Session state should be minimized and stabilized, for example through SSL session caching that removes repeated heavy struct initialization. Likewise, eliminating unnecessary partial stream-level encryption steps where architecture allows can simplify critical paths. The end state is a continuous framed-stream processing model that stays close to full transport utilization without sacrificing structural clarity.

New Inventions

In Beyond the Socket API: Understanding TCP, UDP, and Real-World Network Stack Behavior, the proposed Linux-kernel evolution combines message-framed in-kernel handling, user-space shared memory, and custom syscall extensions, including epoll-class enhancements. The strategic intention behind this proposal is to move protocol orchestration closer to the points where copying, scheduling, and privilege-boundary transitions can be controlled with far greater precision.

io_uring already demonstrates a related paradigm through shared memory rings where the kernel boundary is crossed primarily for control signaling while payload movement can approach true zero-copy behavior. The proposed model extends this trajectory with a stronger framing contract, so that performance improvements are coupled to tighter security properties instead of being treated as an isolated throughput exercise.

External References

Conclusion

The ESP32-S3 PONG architecture is a practical proof that software quality, not hardware size, is the primary determinant of system efficiency. A 240 MHz-class microcontroller can deliver responsive, web-controlled real-time behavior when the stack is intentionally engineered and each subsystem is aligned with the strengths of the platform.

The same discipline scales directly to x86_64. Enterprise systems do not become efficient by default simply because they run on larger processors; they become efficient when memory movement, kernel boundaries, protocol framing, and concurrency models are designed as one coherent architecture. In that sense, the ESP32-S3 result is not a niche embedded anecdote. It is a compact demonstration of a universal engineering law: place the right optimization primitive at the right layer, and performance follows.