The Beetle That Carried the Light

I. The Light Before the Word

Before the first pyramid, before the first word was carved into stone, there was only Ra’s light — endless, indifferent, spilling over sand and river alike, asking nothing and giving no direction. Light without a path is just light. Someone, something, had to move it.

Thoth kept the words. Every incantation ever spoken, every spell ever written in the sacred script, lived in his keeping — and no two were ever quite the same. A farmer’s charm for rain was not a priest’s rite for the dead, was not a king’s decree carved to outlast his own bones. Thoth cared nothing for which words a person chose to speak. He only kept them, infinite and unjudging, waiting for a voice.

And between the two — between the light that meant nothing until it moved, and the words that meant nothing until they were carried — there was Khepri.

He was small. He owned no light of his own, and spoke no incantation that was his. He simply rolled — a plain, dark shell against a rising sun, pushing forward whatever the morning gave him to push, asking no tribute, keeping no secrets, caring not at all whether what he carried belonged to a king or a farmer. Each dawn was the same to him: something arrived that needed carrying, and he carried it.

II. What a Beetle Is For

That’s also, more or less, what a game engine is for.

A little while back I wrote about the ship that became sunlight — the engine underneath, the part that actually knows how pixels get on screen, what happens when two things collide, what a tile map even is. I said then that sunlight wasn’t the end of it, that it had already become the rendering backend for something else I was building on top, and that I’d leave what that something else turned out to be for later. This is later.

Scarab turned out to be almost nothing, on purpose.

Not nothing in the sense of unfinished — nothing in the sense that it doesn’t own a single line of game logic anywhere inside it. It’s a thin C++17 shell around sunlight: it opens the window, loads whatever Lua you hand it, and calls straight into a handful of functions that script defines — an on_update called every frame, an on_load_stage when a stage begins, and so on. Everything that makes one game different from another — the ship, the aliens, how a level unfolds, what happens when you die — lives entirely in that Lua, calling back into a few hundred small verbs Scarab exposes: acquire a sprite, load a map, play a sound, start a timer. You could point it at a vertical shooter one day and a puzzle game the next, and it would never know the difference, because it isn’t supposed to.

The smallest possible game Scarab will actually run looks like this, in full:

-- main.lua — the entire "game"

function on_load_stage(stageId)
    return true -- Scarab has no opinion on what a stage means; Lua claims it
end

function on_update(dt)
    draw_text("Hello, Khepri", 10, 10, 20)
end

sp_wait(1) -- queue at least one command, or the beetle refuses to roll at all

That’s it. No class to subclass, no engine object to instantiate, no build step of its own — one file, two callbacks C++ will call back into, and a scatter of verbs like draw_text standing in for the whole rest of the engine underneath. A whole game, script and every asset it needs, can travel as a single sealed .zip — Scarab reads out of one exactly the way it reads out of a loose folder on disk, no difference at all from the inside. One incantation, whole, ready to be handed to anything that knows how to carry it.

That’s Khepri’s whole trick, really. He never asked what he was rolling. He just rolled it.

III. Not a God’s Throne Room

Every pantheon has a jealous god somewhere — one who guards his own light behind ritual and tribute, who’ll carry your incantation only if it’s written in his own priesthood’s tongue, and who charges for the privilege of being carried at all. He’s not evil, exactly. He’s just decided that what he offers is worth owning, and that ownership means someone else has to ask permission.

Scarab doesn’t ask permission for anything, and it doesn’t sell what it doesn’t have to sell. It’s released under the zlib license — about as close to “take it, use it, sell your own game built on it, just don’t pretend you wrote the engine yourself” as a license gets. There’s no tier where the real features live behind a paywall, because there’s nothing in here worth locking away in the first place: no game logic, no assets, nothing proprietary. What you get is a beetle, not a god’s throne room.

The clearest place this actually shows up is the newest thing I’ve built into it: a way to encrypt a game’s own files, so a Lua script and its assets can be bundled up tight enough that a casual look won’t just hand them over.

The whole workflow is three commands, from a source tree to a sealed, encrypted archive only your own build can open:

# 1. Generate a key of your own (32 random bytes, hex-encoded) and
#    build scarab with it baked in
python3 -c "import secrets; print(secrets.token_hex(32))"
cmake -B build -S . -DSCARAB_CONTENT_KEY=<the 64 hex characters just generated>
cmake --build build -j 4

# 2. Point --pack at a game's own source directory
echo '{ "source_dir": "/path/to/my_game_source", "output": "/path/to/my_game.zip" }' > pack-config.json
./build/scarab --pack pack-config.json

# 3. Run it back - only this exact build, with this exact key, can open it
./build/scarab /path/to/my_game.zip

Nothing exotic underneath: a 256-bit key handed to CMake at build time, a small JSON file telling the packer which folder to seal and where to write the result, and the same executable used both to seal it and to open it again. Anyone who builds their own private copy this way — with a key of their own choosing, kept somewhere only they can see it — gets a bundle nobody else’s build can read.

The tempting version of this feature — the one a jealous god would build — bakes in one secret key for everybody, ships it quietly in every public download, and lets you assume you’re protected. Scarab does the opposite on purpose: the key every public download actually ships with is worked out fresh for each release, from a private formula, different every time — and still, every one of those downloads prints out loud, every single time you run it, that this particular key was never meant to be private, and that anyone willing to spend five minutes with a disassembler can pull it straight out of the binary. If you want the real thing, you build your own copy with your own key, kept where only you can see it. Nothing hidden, nothing implied. The idol lies by omission; Khepri just tells you what he is.

IV. Two Priesthoods

None of this works if Scarab tries to own everything itself, which is why it doesn’t even try. sunlight is its own project now, with its own repository, its own releases, its own author’s-worth-of-decisions that Scarab has no say in — and that separation isn’t a limitation, it’s the whole point. When something breaks at the seam between them, neither side can just reach over and fix the other’s code. What actually happens is closer to two priesthoods comparing notes: a precise account of exactly what’s wrong, checked line by line against the real source before a word of it is sent, handed across, and answered with a real fix and a real test — not a favor, an actual collaboration between two things that owe each other nothing.

A multi-file bitmap font — the kind that’s a text file plus a separate image, rather than one neat file — refused to load through Scarab at all, and for a long time nobody could say why beyond “it just doesn’t.” Every resource Scarab loads — a texture, a sound, a script, that same font — is meant to go through one shared reading point sunlight maintains, precisely so a loose folder of files and a single sealed .zip get treated exactly the same way underneath. The whole project is just a folder with one small manifest at its root:

// project.json
{ "main_script": "src/main.lua" }
# Run it straight from the loose folder...
./scarab project.json

# ...or seal the exact same folder into one file and run that instead
./scarab --pack pack-config.json
./scarab my_game.zip

main_script resolves relative to project.json‘s own location, not wherever scarab happens to be run from — so the folder above works untouched whether it’s sitting loose on disk or packed whole into my_game.zip. Neither main_script nor a single line of the game’s own Lua needs to know or care which of the two it’s actually running from; that’s the shared reading point’s whole job, not something each resource load has to handle for itself.

Tracing the bug all the way down showed the image half of the font quietly slipping past that shared point instead of through it — asked for by a path that had grown a stray ./ in front of it, which the reader underneath rejected without a word. No error, no warning, just a fallback font appearing where the right one should have been, and nothing in the logs to say a substitution had even happened. That got fixed, verified, shipped.

And then it broke again, in a way the first fix couldn’t have caught: the font’s own text half — the part that isn’t an image at all — turned out to have never been wired into that shared reading point in the first place. It had simply never been asked to matter, because on every machine anyone had tested it on, the real file happened to be sitting right there on disk regardless, quietly answering a question nobody knew they were still asking wrong. It only broke once a game shipped as one sealed bundle with nothing loose left lying around to rescue it — which is, not coincidentally, exactly the situation Scarab exists to make normal. Fixed the same way as the first: found for real, described precisely, handed across, verified independently by both sides before anyone called it done.

Twice, the failure was the same shape — something silently standing in for what should have been there, saying nothing about it. Khepri doesn’t do that. When he can’t carry something, he stops, and he says why.

V. Another Dawn

Ra didn’t stop making light because Khepri rolled it once. Tomorrow, there’ll be another dawn, another stretch of sand needing exactly the same small labor as today’s — and Khepri will be there again, not because the work was left unfinished, but because that’s simply what the work is. It was never going to be a monument. It was always going to be a practice.

Scarab’s first real version shipped with just enough to actually run a game — a window, a way to load Lua, the bare minimum a story needs before it can move at all. Every feature since has been added because some actual game needed it, never because a roadmap said so. As of v0.1.13, that list looks like this:

  • Sprites — texture-backed, animated, pooled by handle
  • Collision — shape-based detection between sprites
  • Tile maps — Tiled (.tmx) map loading and per-layer rendering
  • Camera — scrolling/following a target across a map
  • Sound — one-shot effects and streamed background songs
  • Timers — background-thread scheduled callbacks back into Lua
  • Input — keyboard, mouse, and multiple gamepads
  • Text — TrueType and multi-file bitmap fonts
  • JSON — reading arbitrary config/data files from Lua
  • A scripting/sequencing layer — queuing stage transitions and scripted waits without hand-rolled state machines
  • Content encryption — sealing a whole game’s Lua and assets into one encrypted .zip, openable only by the build it was sealed for
  • A packaging tool (--pack) — turning a loose source folder into that sealed .zip with one command

None of that was there on day one, and none of it is the last of it either.

Sometime back, I ended a story about a transforming ship by saying more forms were coming, without saying what they’d be. This was one of them. It won’t be the last.

Enjoy.

[]’s
PopolonY2k


Scarab GitHub project link

https://github.com/popolony2k/scarab

RIP John C. Dvorak (1946–2026)

Today the tech world says goodbye to John C. Dvorak, who passed away on Monday, July 20, 2026, at the age of 80, following complications from recent heart bypass surgery. He is survived by his wife, children, and grandchildren, and remembered by colleagues and readers as a writer who never softened an opinion to make it more comfortable.

A voice from the golden age of tech magazines

My own memory of Dvorak goes back to 1992 or 1993, when I was working in the IT department of a cable manufacturer in Brazil. The company had subscriptions to two or three of the great American tech magazines of the era — PC Magazine, InfoWorld, and, I believe, Byte in its golden years. Somewhere in those pages I ran into Dvorak for the first time, and it was immediately clear this was a writer with strong, unfiltered positions on technology, unafraid to say a product was garbage or that an entire company was heading in the wrong direction. At a time when most technical writing was dry and procedural, his columns had a voice. He wrote for exactly this kind of press — InfoWorld and PC Magazine both carried his columns for decades, and Byte was part of the same world of serious, opinionated computer journalism that shaped how an entire generation of IT professionals thought about the industry.

From print to screen — and beyond

By the late 1990s and into the 2000s, Dvorak had carried that same directness onto television and, eventually, podcasting. His appearances on Computer Chronicles — the long-running PBS show that served as one of America’s first mainstream windows into personal computing — captured that same instinct for opinion and showmanship that print alone couldn’t fully contain. It’s easy to see, watching those episodes now, why the leap into broadcasting felt inevitable for him: he was a natural in front of a camera the same way he was sharp on a page. He went on to appear on CNET Central, NPR, and ZDTV, and later co-hosted the tech show Cranky Geeks.

That instinct for media eventually led him to podcasting, where in 2007 he teamed up with Adam Curry to co-host No Agenda, a show he kept doing right up until his death — nearly two decades of consistent, opinionated broadcasting.

Even in his blog writing, that same instinct for a good, opinionated story never went away. His “Whatever Happened To…” series, still archived on dvorak.org, is a good example — pieces like the one on MSX computers dig into forgotten corners of computing history (in that case, Microsoft’s failed attempt at a home-computer standard in partnership with Japan’s ASCII Corp.) with the same critical eye he brought to his columns decades earlier.

A career that spanned the whole arc of personal computing

Dvorak’s career is really a map of the entire personal computing era — from the earliest days of 8-bit home computers, through the PC and internet booms, into the age of podcasting and blogs. He started as a wine columnist before pivoting to technology, became a fixture at InfoWorld and then PC Magazine (where his column ran for decades), and never stopped writing, broadcasting, or arguing his point of view, right up to the end.

RIP John C. Dvorak.

From a Transforming Ship to a Transforming Engine

Voltron assembles from five lions. The Valkyrie in Macross folds from a fighter jet into a battle-ready mecha. The Autobots have been telling us for forty years that there’s “more than meets the eye.” Transformation is one of gaming and animation’s oldest tricks — take something familiar, and reveal it was always capable of becoming something bigger.

That’s also, more or less, the plot of Caravellius.

The ship that started it all

Back in 2021, I set out to build a shoot-’em-up called Caravellius — the Caravela, an old-world sailing ship, transforms into a spaceship to defend Earth from a Venusian invasion. Here’s the first footage of it taking shape:

Caravellius — early build

I made one deliberate, slightly stubborn decision going in: I wasn’t going to reach for an existing engine. I wanted to actually understand how games get built — cameras, collision, sprites, tile maps, all of it — by writing the plumbing myself.

Two candidates stood out for the actual graphics primitives: raylib and SDL. Rather than pick one and hard-wire the whole game to it, I built a boundary between “my game logic” and “whatever draws the pixels” from day one. If I ever wanted to swap the backend later, I shouldn’t have to rewrite the game to do it.

That boundary is what eventually became sunlight.

The engine transforms

About three months in, sunlight had grown enough of its own identity — map rendering, collision, sprites, sound, a scripting layer — that it stopped feeling like “part of Caravellius” and started feeling like its own thing. So I split it out into its own repository.

That’s the Voltron moment, if you squint: the pieces that had been quietly doing their job inside Caravellius assembled into something that could stand on its own.

Here’s a later look at Caravellius, running on top of that separated engine, with updated art and music:

Caravellius — updated build

Since the split, sunlight has kept leveling up on its own track — a real CI pipeline building it on Linux, macOS, and Windows on every change; a proper unit test suite; five sample projects showing off map rendering, animated sprites, collision, gamepad input, and a scripted “stage intro” cutscene; generated API docs; and, as of this week, its first tagged release, v0.1.0 — with prebuilt packages for all three platforms attached automatically the moment the tag goes up.

The next form

Here’s where the transformation keeps going. sunlight isn’t just Caravellius’s engine anymore — it’s now the rendering backend for Scarab, a new game engine I’m building that adds Lua scripting on top. Where sunlight handles the “how do pixels get on screen and what happens when two things collide,” Scarab is meant to let game logic live in Lua instead of C++.

It’s the same shape as the Caravela becoming a spaceship: the thing underneath didn’t get thrown away when it transformed. It got carried forward into something that could do more.

sunlight is open source under the zlib license — the repo, docs, and the v0.1.0 release are all up on GitHub if you want to see how a ship-turned-spaceship’s engine turned out:

github.com/popolony2k/sunlight

More forms to come.

Enjoy

[]’s

PopolonY2k

Building a Custom Drone Controller from Scratch — Part 5: BLE HID Gamepad Host

In the original Macross series, the SDF-1 doesn’t set out to fight anyone. It launches because an alien fleet is already on its way and there is no other choice. The ship is barely understood, its fold drive untested, its crew a mix of civilians and soldiers who never signed up for an interstellar war. They leave Earth with what they have, figure out the rest along the way, and somehow keep flying.

That’s roughly the spirit in which this project approaches Bluetooth gamepad support.

After Part 4, the drone flew. Tilt control — mapping the AtomS3’s accelerometer to roll and pitch — worked well enough to navigate a room. But holding a small board at a precise angle for minutes at a time is genuinely tiring. Fatigue tremors start showing up in the roll and pitch axes. After a few sessions the wrists were unhappy and the drone was responding to them more than to intent.

The obvious solution: a gamepad. The ESP32-S3 has a Bluetooth radio. Gamepads speak Bluetooth. The SDF-1 had a fold drive. Let’s fire it up and see where we end up.

The Fold Drive — BLE HID Host

The ESP32-S3 supports BLE — Bluetooth Low Energy. The Arduino ESP32 platform ships a BLE library (BLEDevice.h) that can act as a HID host: scan for devices advertising the HID service (UUID 0x1812), connect, subscribe to Input Report notifications, and read raw bytes. No extra lib_deps entry required — the library is already aboard.

The approach is deliberately simple. No gamepad abstraction layer, no universal report parser. Scan for HID, connect, log the raw bytes, figure out the format, map it to flight axes. Every controller does its own thing with HID reports, so the format has to be discovered per-device anyway.

Following the project’s discipline on dependencies, all of this lives in src/bt/ble_gamepad.cpp. Nothing outside that file ever sees a BLEDevice or a BLEClient. The rest of the firmware receives a GamepadAxes struct — normalised values in [−1, 1], fully decoded — and never knows whether they came from Bluetooth, a touchscreen, or a Zentradi battle pod.

The fold drive is spinning. Now we need somewhere to go.

Unexpected Destination — the iPega PG-9021S

In Macross, the first fold jump doesn’t go where anyone planned. The SDF-1 executes the fold and ends up near Pluto — not Earth orbit, not anywhere useful — with the entire civilian city of Macross dragged along for the ride. It is not the mission. But the ship is intact, the crew is alive, and they have no choice but to work with where they are.

The first controller in hand was an iPega PG-9021S — a telescoping gamepad designed for phones, with two analog sticks, a D-pad, four face buttons, and shoulder triggers. It supports multiple pairing modes triggered by different button combos at power-on.

The first attempt: HOME + X — Android Standard Gamepad mode. The controller connected, appeared in the BLE scan, handshaked — and then disconnected. Every time. The root cause, after some digging, was BLE bonding. Standard Gamepad mode requires a security handshake that the ESP32 Arduino BLE library doesn’t perform. Without it, the controller politely refuses to stay paired.

The second attempt: HOME + A. Connected. Stayed connected. Data flowing.

This is an undocumented mode added in a 2019 iPega firmware update, officially called “Direct Play.” Designed for mobile games that read touch input directly, bypassing Android’s controller API entirely. The name should have been a warning about the destination.

In HOME+A mode, the iPega does not present itself as a gamepad. It presents itself as a touchscreen.

Not Pluto. But intact. And flying.

Reverse-Engineering Zentradi Technology — the 17-Byte Report

The Zentradi’s technology was incomprehensible to humans at first contact. It took time, captured ships, and a lot of dangerous guesswork to figure out what any of it actually did. The understanding, when it came, was hard-won.

When the iPega connects in HOME+A mode, it advertises HID Usage Page 0x0D (Digitizer), Usage 0x04 (Touch Screen). Every input report is 17 bytes — four 4-byte contact blocks, followed by a 1-byte contact count. Exactly how a multi-touch phone screen reports fingers.

Each 4-byte contact block is little-endian bit-packed:

bit 0:      Tip Switch (1 = finger touching)
bit 1:      In Range
bits 2–3:   padding
bits 4–7:   Contact ID (always 0 — both sticks share it)
bits 8–19:  X coordinate  (0–1200)
bits 20–31: Y coordinate  (0–2200, top = 0)

Extracting X and Y from bytes b[1]b[2]b[3]:

uint16_t X = b[1] | ((b[2] & 0x0F) << 8);
uint16_t Y = (b[2] >> 4) | (b[3] << 4);

The controller’s screen is portrait internally, held in landscape. Physical UP/DOWN maps to X (up = X increases); LEFT/RIGHT maps to Y (left = Y increases).

Both sticks report Contact ID 0 — there is no field to distinguish left from right. The solution: Y coordinate. Values below 1000 are the left stick area; 1000 and above are the right stick area. The virtual touchscreen was laid out with enough separation that this split is reliable.

Technology understood. Now it can be used.

Calibration — Learning to Aim

Hikaru Ichijo wasn’t a natural in a Valkyrie on day one. He logged hours in the simulator, then more hours in real combat, learning how the machine responded before he could trust it.

Calibration here was simpler: move each stick to its extremes and resting center while logging raw reports. The numbers that came out:

AxisStickCoordinateCenterRange
ThrottleLeft UP/DOWNX523±300
YawLeft LEFT/RIGHTY500±240
PitchRight UP/DOWNX533±145
RollRight LEFT/RIGHTY1650±120

Each axis normalises to [−1, 1]: (coordinate − center) / range. The result then passes through the same dead zone, expo curve, and slew-rate limiter used by the tilt controller — so switching between control modes doesn’t change the feel of the drone.

Both drones run altitude-hold firmware. Center stick means maintain altitude; deflect to climb or descend; release and the drone hovers wherever it is. The critical detail: the moment the stick returns inside the dead band, throttle snaps immediately back to neutral (0x80). Leave a residual value and the drone keeps climbing forever. Get that snap-back right and altitude control feels natural.

The FAST Packs — Mapping the Buttons

The FAST Packs in Macross weren’t part of the original Valkyrie design. They were add-ons — extra weapon and propulsion modules bolted onto a frame that was never built to carry them. They worked because the engineers understood the underlying system well enough to extend it.

Buttons in HOME+A mode appear as additional touch contacts at fixed (x, y) positions. The iPega is emulating PUBG Mobile’s on-screen button layout — each physical button maps to a virtual finger tap on a specific point of the touchscreen. There is no HID button bitmask. Discovery was purely empirical: press one button at a time, log the 17-byte report, note the coordinates.

ButtonxyDrone command
A3322044Arm + takeoff
B7801281Land
X2411270Emergency stop
Y8182020360° flip
D-pad UP723544Headless mode toggle
D-pad DOWN352562Calibrate gyro
D-pad LEFT534379Toggle screen on/off
LT1173416Lock motors
R15782041Unlock motors

Detection uses ±30 pixel tolerance on each coordinate. Contacts are checked against this table before the stick zone classifier, so a button press near a zone boundary doesn’t accidentally register as axis movement.

Nine buttons. Working. The FAST Packs are on.

A Battle Won, A War Lost — the L1/RT/L3/R3 Investigation

Not every engagement in Macross ends cleanly. There are missions where the Valkyrie comes back intact but the objective wasn’t fully achieved — where you did everything right and it still wasn’t enough, for reasons outside your control.

Four buttons — L1, RT, L3, R3 — never appeared in any log. No contact, no matter how hard or how long they were pressed.

The controller was opened. The switches under all four had cracked solder joints — a common failure on telescoping gamepads that get repeatedly flexed. All four were resoldered. The controller went back together.

Plugged into an Android phone running a gamepad test app, in Standard Gamepad mode (HOME+X): all four buttons registered immediately. Clean signals. The hardware was fixed.

Back on the AtomS3, in HOME+A mode, with both analog sticks held deflected to keep two contacts active: press L1. The contact count never reached three. No third contact appeared for L1, RT, L3, or R3, in any combination.

The explanation is in what Direct Play mode actually emulates. PUBG Mobile’s touchscreen UI was designed for a phone. It never had L1, RT, L3, or R3 — those concepts don’t exist in a mobile touch game. The iPega’s firmware never assigned those buttons a position on the virtual screen. The hardware repair was real and complete. The limitation is a protocol design decision made years ago for a different platform entirely.

“It registers in Android’s controller test” and “it generates a contact in this HID profile” are two different questions. This was a useful reminder that confirming something works in one context says nothing about whether it works in another. The battle with the solder joints was won. The war for those four buttons was already lost before the controller was even opened.

We fly with nine.

The Zentradi Main Fleet — 8BitDo

In Macross, the first Zentradi ships are manageable. A squadron here, a patrol fleet there. The SDF-1 fights them off, learns their patterns, adapts. Then the main fleet arrives. Seven million warships. Not a battle. A wall.

With the iPega working, a natural question arose: could a proper console-style gamepad work? On hand: an 8BitDo SF30 Pro, an 8BitDo Zero 2, an 8BitDo Ultimate 2C, and an Xbox One S. All marketed as Bluetooth controllers. None of them connected. Every BLE scan came back empty.

LightBlue, a Bluetooth scanner app on a phone, also saw nothing from these controllers. But Android’s native Bluetooth pairing screen found the 8BitDo Zero 2 and Ultimate 2C instantly — listed as “Pro Controller.”

That name was the answer. “Pro Controller” is Nintendo’s Switch Pro Controller. 8BitDo’s Switch-emulation mode — activated by X+Start or the model-specific equivalent — makes the controller advertise itself as one. Android finds it because Android’s Bluetooth stack supports Classic Bluetooth, known as BR/EDR. The Pro Controller protocol runs over BR/EDR.

The ESP32-S3 does not support BR/EDR. There is no Classic Bluetooth radio on the die. This isn’t a software configuration — it’s the chip itself. Espressif’s own datasheet confirms it. The Bluepad32 project’s FAQ states it plainly:

“Only BLE gamepads are supported on ESP32-S3, since BR/EDR is not supported… Controllers like Switch, Wii, DualSense, DualShock, etc. only talk BR/EDR.”

Every failed attempt was the correct outcome. Four controllers, many pairing modes, many attempts. All correct. No sequence, no library, no firmware trick changes this. It is the hardware. The only ESP32 variant with a dual-mode radio — BLE and BR/EDR together — is the original ESP32, not the S3, C3, C6, or H2 variants. The AtomS3 runs an S3.

Seven million warships. This one doesn’t get won today.

Do You Remember Love — What We Have

At the end of Macross, Earth is not what it was. The battle left marks that don’t erase. But Hikaru is alive. Misa is alive. The SDF-1 is battered but standing. And somewhere in the ruins, Minmay is still singing — because culture and music turned out to matter more than anyone expected, and the proof of that is that anyone is around to remember it.

The BLE gamepad mode works. The iPega PG-9021S in HOME+A mode — a touchscreen disguised as a gamepad, discovered by accident when the obvious path was blocked — gives full analog control of all four flight axes plus nine functional buttons. The flight feel matches the tilt controller for roll and pitch, with the ergonomic advantage of a real grip in your hands instead of a board held sideways.

Nine of thirteen buttons. Four will never come. SELECT and START are reserved by the controller’s firmware for mode-switching and never appear in any HID report. The iPega works with what it is, which is more than enough to fly.

8BitDo controllers cannot work on this hardware. The AtomS3’s ESP32-S3 simply does not have the radio they speak. That question has been asked thoroughly, answered conclusively, and put to rest.

The SDF-1 didn’t come home with everything it left with. But it came home. The drone flies. And somewhere out there, the universe is still very large.

Next: a second drone arrives, with a completely different protocol hidden inside — and it took a packet capture to find out what it actually spoke.

Enjoy — PopolonY2k

Building a Custom Drone Controller from Scratch — Part 4: Accelerometer Tilt Control

If you’ve seen Pacific Rim, you know the core idea behind a Jaeger: a pilot doesn’t push buttons to make the giant robot punch — they punch, and the robot punches with them. The machine reads the pilot’s body and mirrors it. That’s the exact idea behind the first control mode I built for the Maritaca Force 1 and Dr.One drones: you tilt the controller, the drone tilts with you.

No joystick, no buttons for movement — just an M5Stack AtomS3 in your hand, leaning the way you want the drone to fly. In this article I’ll walk through how that actually works under the hood: turning raw motion-sensor numbers into smooth flight commands, the gesture I use for throttle (which is not tilt-based, for a good reason), and a yaw-control redesign that turned out to accidentally fix a completely unrelated bug.

The big idea: tilt is data, not magic

Inside the AtomS3 sits a small chip that measures two different things: acceleration (how the board is oriented relative to gravity) and rotation rate (how fast it’s spinning). Tilt the board to the left, and gravity “pulls” more on one axis than another — from that pull, we can calculate an angle. That angle is the only ingredient we need for roll and pitch.

float pitchDeg = atan2f(imu.ax, sqrtf(imu.ay * imu.ay + imu.az * imu.az)) * (180.0f / M_PI);
float rollDeg  = atan2f(imu.ay, sqrtf(imu.ax * imu.ax + imu.az * imu.az)) * (180.0f / M_PI);

Don’t worry about the trigonometry — the short version is: atan2f converts the raw gravity readings on each axis into an angle in degrees. Tilt the board 15° to the right, and rollDeg comes out as roughly 15. That’s it. From here on, it’s all about turning that number into something a drone can actually use safely.

From a raw angle to a flight command

If we sent that raw angle straight to the drone, two things would go wrong immediately. First, your hand is never perfectly still — even resting flat, there’s a tiny natural tremor that the sensor happily reports as “tilt.” Second, a twitchy, instant response to every micro-movement would make the drone feel nervous and hard to fly precisely.

Two small ideas fix both problems:

  • Dead zone — ignore any tilt smaller than about 10°. Below that threshold, we treat the board as “flat,” even if the sensor reports a tiny non-zero number. This is what keeps the drone from drifting on its own just because your hand isn’t a tripod.
  • Expo curve — once you’re past the dead zone, small additional tilts produce small movements, and big tilts produce big movements, but the relationship isn’t a straight line — it curves, so fine control near the center is easier and the extremes are still reachable.
uint8_t AccelController::mapAxis(float value, float maxRange, float deadZone, float expo) {
    if (fabsf(value) &lt; deadZone) return 0x80; // 0x80 = neutral, dead-center

    float sign   = value &gt; 0.0f ? 1.0f : -1.0f;
    float scaled = (fabsf(value) - deadZone) / (maxRange - deadZone);
    if (scaled &gt; 1.0f) scaled = 1.0f;

    scaled = scaled * ((1.0f - expo) + expo * scaled); // the "curve"
    return (uint8_t)((0.5f + sign * scaled * 0.5f) * 254.0f);
}

One more ingredient, and this is the one that actually makes it feel like piloting rather than flicking a switch: a slew rate limiter. Even if you snap the board from flat to a hard tilt instantly, the output value isn’t allowed to jump instantly — it ramps there over a fraction of a second. It’s the same principle as a car’s steering having weight to it instead of feeling like an on/off switch. Tiny detail, huge difference in how trustworthy the drone feels in your hand.

Throttle: the one gesture that isn’t tilt

Here’s a detail that surprised me during testing: both drones run what’s called altitude-hold firmware. Left alone, they actively fight to stay at whatever height they’re already at — like a Jaeger’s auto-balance systems quietly correcting your footing so you don’t topple over. That’s great for a beginner-friendly drone, but it means throttle can’t just be “tilt forward = climb” the way roll and pitch are tilt-based. Instead, throttle uses the only physical button on the AtomS3:

  • Press and hold the screen button → climb
  • Click once, then press and hold → descend
  • Let go → the drone snaps back to hovering at whatever height it just reached

That last part — snapping back to neutral the instant you release — took a real bug to discover. My first version kept whatever throttle value you’d built up even after you let go, which sounds harmless until you realize it means the drone keeps climbing (or sinking) forever after you’ve stopped touching anything. On altitude-hold firmware, “neutral” isn’t zero — it’s “stay right here,” so the fix was just snapping back to that neutral value the moment your thumb leaves the button.

The yaw redesign: when “more realistic” makes things worse

This is the part of the story I actually want to tell, because it’s a good example of a control scheme that looked elegant on paper and was miserable in practice.

My first idea for yaw (spinning the drone left/right in place) was to use the gyroscope’s twist-rate — physically rotate the flat board like a steering wheel lying on a table, and the drone spins to match. It’s the most “drift-compatible-pilot” idea of the whole project: your hand’s rotation becomes the robot’s rotation, no abstraction in between.

It was also nearly unflyable. Twisting a small board flat on your palm, with enough precision to stop exactly where you want, turns out to be a genuinely hard physical motion — much harder than tilting it, which your wrist already does naturally. Worse, any tiny ambient drift in the gyroscope reading (sensors are never perfectly zeroed) had a path all the way to the drone’s yaw command, which I suspect caused an unrelated mystery: the drone would slowly spin on the ground before even taking off, for no command I could find in the logs.

The fix ended up being almost embarrassingly simple. Instead of inventing a new gesture, I reused the existing one: a single click of the screen button toggles a “yaw mode,” and while it’s on, the same left/right tilt that normally controls roll gets rerouted to control yaw instead:

out.roll  = yawModeActive ? DroneAxis::NEUTRAL    : (uint8_t)_currentRoll;
out.yaw   = yawModeActive ? (uint8_t)_currentRoll : DroneAxis::NEUTRAL;

Click once, tilt left/right to spin in place; click again, and the same tilt goes back to strafing. No new motion to learn, and the gyroscope is no longer involved in flight at all — which meant that mystery ground-spin bug disappeared the moment this shipped, without me touching it directly. Sometimes the best fix for a bug is removing the entire mechanism it was hiding in.

What’s next

Tilt control gets you flying with nothing but your hand and a tiny screen, but it has a ceiling — there’s only so much precision your wrist can offer, and only one physical button to work with. In the next article, I’ll cover the second control mode I built: a full Bluetooth gamepad host running directly on the ESP32, including a very unexpected discovery about how a $15 controller pretends to be a touchscreen.

Enjoy

[]’s
PopolonY2k