Esp32 s3 microcontroller web application controlled pong arcade game 3.5mb
posted on 14 Aug 2026 under category programming
| Date | Language | Author | Description |
|---|---|---|---|
| 14.08.2026 | English | Claus Prüfer (Chief Prüfer) | An ESP32-S3 Microcontroller Web Application-Controlled PONG Arcade Game |



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.
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:
std::string_view, std::span, std::spanstream, and bounded request bufferingEven with frontend assets included, the final monolithic firmware remains near 3.5 MB.
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.
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::arrayAt runtime, Filesystem::getFileMetadata() in src/components/filesystem/Filesystem.hpp simply walks that compile-time array and returns a ServerFile object containing:
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:
Filesystem::getFileMetadata()FileMetadata.ContentPointer and FileMetadata.ContentLength into MsgSetBodyRef()httpgenerator.cpp send header and body in two phasesThis 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:
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.
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 HTTP side handles:
The main loop handles:
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:
ASRequestStatusASRequestIDASRequestContentLengthASRequestExchangeBuffer[2048]The flow is straightforward:
ASRequestHandler.cpp matches an HTTP request against the declarative route list in ASRequestDef.hppASRequestStatus to AS_REQ_PROCESSINGClientHandler.cpp sees AS_REQ_PROCESSED, wraps the shared buffer as JSON response body, sends it, and resets the state to AS_REQ_WAIT_INThis 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:
ASRequestDef.hppThat 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.
network_oopThe 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 activevTaskDelay(1) when requests are in flightThis 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:
In practical terms, this gives the browser controls a “real-time” feel because the control request path stays short and predictable.
httpparser.cpp And httpgenerator.cpp As Performance-Critical ComponentsThe 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_viewstd::spanstd::spanstreamunordered_map lookup supportThose 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 presentContent-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 requeststring_view slicesOn 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.
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:
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:
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:
GameRef, so there is no per-request re-creation of the world staterender_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_PUCK_VELOCITIES, avoiding repeated expensive randomness logic during gameplayThis 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:
That is why the MicroPython layer remains practical on the ESP32-S3 instead of becoming the bottleneck.
This result is driven by three development paradigms:
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.
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.
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.
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.
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:
The principle is universal: use the right computational mechanism at the right layer.
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.
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.
For practical implementation details and production-grade queue interfaces, see liburing (axboe/liburing).
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.