Esp32 s3 microcontroller web application controlled pong arcade game 3.5mb

  • embedded
  • esp32-s3
  • micropython
  • esp-idf
  • cplusplus
  • performance
  • networking
  • 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

EmojiRocketEmojiRocketEmojiRocket

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.

Architecture

The implementation is built on an Espressif ESP32-S3 Mini with FreeRTOS task concurrency exposed through a POSIX Threads (pthread) wrapper.

Project repository: WEBcodeX1/micropython-as

Core architecture components:

  • Captive Wi-Fi Access Point (WPA2) with integrated DHCP server
  • Minimal DNS server (+EDNS0), running as a dedicated RTOS task
  • RGB LED pulse controller with task-triggered color transitions
  • Embedded MicroPython runtime in the main application context
  • HTTP/1.1 static web server, running in a dedicated RTOS task
  • Compile-time generated FlashROM filesystem with 66 embedded web assets
  • Zero-copy HTTP body delivery via direct body-pointer handoff into the response generator
  • lwIP IPv4 network stack over encrypted Wi-Fi with up to two parallel control clients
  • Non-blocking socket server with explicit accept / receive / parse / send stages
  • C++23 HTTP parser using std::string_view, std::span, std::spanstream, and bounded request buffering
  • Real-time PONG rendering on an I2C SSD1306 OLED display
  • Modified SSD1306 driver with explicit page-buffer ownership and deterministic full-frame transfer behavior
  • Player Versus Player (PvP) and Player Versus AI (PvAI) gameplay modes
  • HTTP/1.1 JSON application API as the MicroPython control interface
  • Embedded browser-based controls for up to two simultaneous players
  • Optimized frontend assets (Bootstrap subset, Font Awesome subset, icons)
  • Multi-language frontend text (German and English)
  • Declarative frontend form setup with real-time validation support

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.

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.

1. Static FlashROM Filesystem And Zero-Copy HTTP Delivery

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:

  • filedata.h: static const unsigned char fileN[...] = { ... }
  • filemetadata.h: ServerFile metadata records plus a compile-time std::array

At runtime, Filesystem::getFileMetadata() in src/components/filesystem/Filesystem.hpp simply walks that compile-time array and returns a ServerFile object containing:

  • URL path
  • content type
  • direct pointer to the embedded file bytes
  • exact content length

That 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 src/components/network_oop/ClientHandler.cpp is extremely 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 matters 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.

It is also a strong architectural decision because it removes an entire failure class:

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

For this project, where the frontend is fixed and known at build time, this choice is far more coherent than adding a general-purpose embedded filesystem just for architectural fashion.

2. Application Server Layer, JSON Processing, And Task Synchronization

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

The HTTP side handles:

  • 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

The main loop handles:

  • 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]

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

This is not merely “simple code”; it is a deliberately cheap mailbox design. The browser API is easy to define because adding a new endpoint is mostly a matter of:

  • adding one route entry in ASRequestDef.hpp
  • assigning an ID
  • adding one branch in the main loop or interpreter-dispatch section

That makes the application-server part highly integratable and programmable without losing control of timing. The architecture therefore achieves something many embedded web stacks fail to achieve: the HTTP API remains easy to extend, but the execution path from socket to game action stays compact enough to preserve responsiveness.

3. Non-Blocking HTTP Server Structure Inside network_oop

The network_oop server code deserves much more credit than a short mention. It is effectively the C++ application-server runtime for the whole project.

Server.cpp builds the listening socket, enables TCP_NODELAY, makes the socket non-blocking, and uses poll() in a tight loop. That means the implementation is not based on blocking per-client threads. Instead, there is one accept/process loop with explicit idle behavior:

  • vTaskDelay(10) when no messages are active
  • vTaskDelay(1) when requests are in flight

This is a very suitable design for an ESP32-class controller because it keeps concurrency explicit and bounded.

Client.cpp then drains all currently available bytes from each client socket in a loop and feeds them into the parser. ClientHandler.cpp keeps ownership of the live client map, request queue, send-state machine, and cleanup set. The important architectural consequence is that request parsing, request dispatch, response generation, and partial-write continuation all live in one coherent component graph instead of being spread across unrelated callbacks.

That design is especially useful for browser paddle control:

  • the server does not block on slow clients
  • partial writes are resumed correctly
  • keep-alive is preserved
  • POST requests can carry JSON payloads without forcing a new connection per action
  • static GET traffic and application POST traffic are handled in the same state machine

In practical terms, this gives the browser controls a “real-time” feel because the control request path stays short and predictable.

4. httpparser.cpp And httpgenerator.cpp As Performance-Critical Components

The article should also state clearly that the “architecture” is not only about task layout. It is also about the fact that the project uses a dedicated HTTP library whose implementation is itself tuned for low-overhead operation.

The WEBcodeX1/http-1.2 library used by the project is not a generic heavyweight framework. Its current httpparser.cpp implementation is explicitly described as the default C++23 parser and uses:

  • std::string_view
  • std::span
  • std::spanstream
  • heterogeneous unordered_map lookup support
  • bounded request buffering

Those choices are directly relevant to latency:

  • appendBuffer() appends bytes into a bounded request buffer and only starts expensive work after an HTTP end marker is present
  • POST handling tracks Content-Length explicitly and waits only until the declared payload size is available
  • _parseRequestHeaders() wraps the request buffer in a spanstream, so header parsing can walk the raw bytes line by line without building unnecessary intermediate strings for the entire request
  • GET parameter parsing is done from string_view slices

On the response side, httpgenerator.cpp builds only the header block into a small internal buffer and keeps the body as a pointer/length pair. That is exactly why the filesystem integration is so effective: the static file data can remain where it already is, while the generator only advances the current body pointer during partial sends.

Taken together, httpparser.cpp, httpgenerator.cpp, the non-blocking client loop, and TCP_NODELAY form the low-latency control path that makes browser paddle input feel immediate even though the actual game logic runs inside an embedded MicroPython interpreter.

5. SSD1306 Rendering Pipeline And Why Complex Frame Scheduling Is Unnecessary

The display subsystem is also much more engineered than a superficial source comparison would suggest.

src/components/peripherals/Display.cpp wraps the modified SSD1306 driver in a minimal C++ interface:

  • initialize I2C and display once
  • draw lines into the internal page buffer
  • render text
  • flush the buffer with showBuffer()

The lower-level driver in src/components/ssd1306 keeps explicit ownership of an internal 8-page display buffer (SSD1306_t::_page[8]). Drawing functions such as _ssd1306_pixel() and _ssd1306_line() modify that memory first; ssd1306_show_buffer() then transmits the eight pages sequentially over I2C by calling i2c_display_image() once per page.

That matters for timing. The display is configured for a 400 kHz I2C clock in ssd1306_i2c_new.c, and a full 128x64 monochrome frame is transmitted in a fixed page-oriented pattern. Because the transfer volume per flush is essentially constant, the frame output time is also highly predictable and sits at roughly 30 milliseconds per full frame on this configuration. Architecturally, that is valuable because the project does not need a complicated adaptive refresh scheduler or frame-pacing subsystem. The code can render the next logical frame, flush the buffer, and rely on the physical display transfer time itself as a stable pacing factor.

For a small real-time game, that is a very elegant engineering trade-off:

  • deterministic transfer pattern
  • no double-guessing of refresh intervals
  • no complex FPS bookkeeping
  • very small display abstraction surface

6. MicroPython PONG As A Ported And Timing-Aware Runtime Module

The MicroPython game layer is also far more deliberate than “some Python script embedded into firmware.”

As already documented in the related repository article, the PONG implementation is presented as an AI-engineered port of an earlier standalone game code base into a MicroPython module that can be embedded as pong.h. In the source that matters here, the resulting module has clearly been further engineered for interpreter-side real-time use.

Several details show this:

  • the game is instantiated once as GameRef, so there is no per-request re-creation of the world state
  • the external call surface is intentionally tiny: render_frame(), render_frame_no_dt(), and get_player_id()
  • render_frame_no_dt() hardcodes a 0.03 delta time, which removes the need for external frame-time calculation logic at the control boundary
  • the return path is a compact comma-separated coordinate / score string instead of a large object graph
  • the puck-start vectors are precomputed in _PUCK_VELOCITIES, avoiding repeated expensive randomness logic during gameplay
  • paddle control enters through tiny JSON messages and is converted immediately into simple directional state

This is exactly the kind of shaping required when porting logic into an interpreter for a real-time-ish environment: preserve the mathematical clarity of the original gameplay logic, but aggressively simplify the per-frame runtime contract.

In other words, the architecture does not ask MicroPython to be the network server, the display driver, or the concurrency manager. It asks MicroPython to do what it is good at here:

  • hold game objects
  • run compact frame-step math
  • expose a tiny scriptable control surface

That is why the MicroPython layer remains practical on the ESP32-S3 instead of becoming the bottleneck.

How Is That Size Possible?

This result is driven by three development paradigms:

  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 (/python/startgame, /python/paddleup, /python/paddledown) in src/components/network_oop/ASRequestDef.hpp and ASRequestHandler.cpp, and the main loop dispatches those IDs into the MicroPython function call boundary:

// 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;
}
// 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.

Efficient Interprocess Locking

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.

What Is Wrong With x86_64?

The issue is rarely the hardware. The issue is 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.

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

For practical implementation details and production-grade queue interfaces, see liburing (axboe/liburing).

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.