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

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

Building a Custom Drone Controller from Scratch — Part 3: Firmware Architecture (HAL Pattern, Non-Blocking Loop, and the Flight State Machine)

In Part 1 we talked about why this project exists and what hardware it runs on, and in Part 2 we reverse-engineered the UDP protocol that lets an M5Stack AtomS3 talk to a cheap WiFi toy drone. Before going further into flight control and gamepad support, it’s worth stopping to look at how the firmware itself is put together — because the same skeleton ends up driving two completely different drones (Maritaca Force 1, the black E58-protocol drone, and Dr.One, the grey FLOW-WIFI drone) and two completely different input methods (accelerometer tilt and a Bluetooth gamepad) without duplicating the flight logic.

The full source is on GitHub: github.com/popolony2k/maritaca-e88-controller.

A quick tour of the project structure

The firmware is built with PlatformIO, using plain .cpp/.h files (no .ino) so everything can be unit-structured and reasoned about like normal C++. The layout looks like this:

src/
├── main.cpp                  # wiring + non-blocking main loop
├── hal/
│   ├── hal.h                  # BoardHal, DisplayHal, ImuHal, ButtonHal
│   ├── m5atoms3.h
│   └── m5atoms3.cpp           # only file that includes M5Unified.h
├── imu/
│   └── accelerometer.h/.cpp   # ImuData struct, filtering
├── comm/
│   ├── drone_protocol_base.h  # abstract DroneProtocolBase interface
│   ├── drone_protocol.h/.cpp  # WIFI_8K_ black drone (E58 8-byte)
│   ├── flow_wifi_protocol.h/.cpp # FLOW-WIFI grey drone (88-byte)
│   └── wifi_manager.h/.cpp    # WiFi station + auto-detect scan
├── bt/
│   ├── gamepad_axes.h         # normalized axes, no BLE deps
│   └── ble_gamepad.h/.cpp     # BLE HID host (iPega, 8BitDo)
├── control/
│   ├── accel_controller.h/.cpp   # tilt → roll/pitch/yaw/throttle
│   ├── gamepad_controller.h/.cpp # gamepad axes → DroneState
│   ├── flight_controller.h/.cpp  # the state machine
│   └── operation_mode.h
└── ui/
    └── display.h/.cpp         # pure renderer

Three ideas repeat throughout this tree, and they’re the subject of the rest of this article:

  1. A hardware abstraction layer (HAL) that keeps the M5Stack SDK out of almost every file.
  2. A non-blocking main loop built entirely on millis() — no delay(), anywhere.
  3. A flight state machine that’s shared by both drones and both input modes.

The HAL: one file gets to know about M5Unified

M5Unified is a great library — it gives you the display, the IMU, the button and the power management of the AtomS3 behind one consistent API. The problem is that once #include <M5Unified.h> shows up in a file, that file is now tied to this specific board. For a project that already juggles two drone protocols and two control schemes, we didn’t want a third axis of “which file is allowed to touch the hardware.”

So the rule is simple: only src/hal/m5atoms3.cpp includes M5Unified.h. Everything else — the flight controller, the display renderer, the accelerometer filter — talks to a set of plain interfaces defined in hal.h:

struct BoardHal {
    void (*begin)           ();
    void (*update)          ();
    int  (*getBatteryLevel) ();   // 0/25/50/75/100 in %
    bool (*isCharging)      ();   // always false on this hardware
};

struct ButtonHal {
    bool (*wasPressed)  ();
    bool (*wasReleased) ();
    bool (*pressedFor)  (uint32_t ms);
};

These are structs of function pointers, not abstract classes with virtual methods. The implementation, in m5atoms3.cpp, fills them in with non-capturing lambdas:

const BoardHal kBoard {
    .begin           = [] { auto cfg = M5.config(); M5.begin(cfg); },
    .update          = [] { M5.update(); },
    .getBatteryLevel = [] { return batteryLevel(); },
    .isCharging      = [] { return false; },
};

const ButtonHal kButton {
    .wasPressed  = []()            -> bool { return M5.BtnA.wasPressed(); },
    .wasReleased = []()            -> bool { return M5.BtnA.wasReleased(); },
    .pressedFor  = [](uint32_t ms) -> bool { return (bool)M5.BtnA.pressedFor(ms); },
};

A non-capturing lambda — one that doesn’t reference any outside variables — has no state of its own, so it decays to a plain function pointer at compile time. There’s no closure object, no heap allocation, no vtable lookup. kBoard.update() compiles down to exactly the same code as calling M5.update() directly, but every other file in the project now depends on hal.h (a tiny, dependency-free header) instead of the entire M5Unified SDK.

This pays off in a very concrete way: the battery-level logic — reading GPIO8/ADC2 through a voltage divider, averaging samples, applying hysteresis around the 25/50/75% boundaries — lives entirely inside m5atoms3.cpp as a couple of small static functions. The flight controller and the display just call kBoard.getBatteryLevel() and get back a number from 0–100. When the battery calibration changed (we covered that story in an earlier debugging session), not a single line outside m5atoms3.cpp needed to change.

A loop with no delay()

The AtomS3 in this project is doing a lot at once: polling a button, running an IMU filter, talking to a BLE gamepad, maintaining a WiFi connection, sending UDP control packets at a fixed rate, and redrawing a small LCD — all from a single loop(). If any one of those used delay(), everything else would stall for that duration. A drone waiting for its next 40 ms control packet doesn’t care that the display “only” wanted to sleep for 10 ms.

So the project has a hard rule: no delay() in loop(), ever. Every periodic task instead remembers the last time it ran and checks millis() on every pass:

static uint32_t _lastDisplayMs = 0;
static constexpr uint32_t DISPLAY_INTERVAL_MS = 100; // 10 Hz

void loop() {
    kBoard.update();
    wifi.update();
    imu.update(kImu);

    // ... gamepad + flight controller update, every iteration ...

    uint32_t now = millis();
    if (now - _lastDisplayMs >= DISPLAY_INTERVAL_MS) {
        _lastDisplayMs = now;
        display.update(/* ... */);
    }
}

The same pattern shows up at every layer, each with its own cadence chosen for a reason:

  • Drone control packets go out at ~25 Hz (every ~40 ms) — fast enough for responsive flight, matching what the original Android app does.
  • The keepalive packet (AA 80 80 00 80 00 80 55) fires every ~790 ms when the sticks are idle, so the drone doesn’t think the link has died.
  • The display redraws at 10 Hz — plenty for human eyes, and it keeps SPI traffic from competing with the more time-critical UDP and BLE work.
  • BLE scanning uses 5-second timed windows, restarted every 6 seconds while no gamepad is connected.

Because every one of these is just “a timestamp and a constant,” loop() can run thousands of times a second, doing almost nothing on most iterations and exactly the right thing the moment any of those windows elapses. Nothing ever blocks anything else.

One subtlety we ran into directly because of this design: WiFiUDP::begin() needs WiFi.mode() to have been called at least once to initialize the ESP32’s network stack — even on builds where WiFi is never actually connected. Skip it, and the drone protocol driver crashes during setup(), producing a silent boot loop. It’s a good reminder that “non-blocking” and “stateless” aren’t the same thing — some subsystems still have a required initialization order, even if nothing about them looks synchronous.

The flight state machine

All of the above exists to support the actual brain of the firmware: FlightController. It’s a small state machine with six states:

enum class FlightState {
    Idle,        // disarmed, sending keepalive packets
    Calibrating, // sending CaliGyro for 1.5 s before arming
    Arming,      // Unlock, then TakeOff
    Flying,      // active flight, 25 Hz control packets
    Landing,     // sending Land for 2 s, then back to Idle
    Emergency,   // EmergStop, then immediately back to Idle
};

The normal lifecycle is exactly what you’d expect:

Idle ──(double-click + WiFi ok)──▶ Calibrating ──▶ Arming ──▶ Flying
Flying ──(double-click)──▶ Landing ──▶ Idle
any state ──(triple-click)──▶ Emergency ──▶ Idle

Every transition goes through one function, enterState(), which resets all the per-state bookkeeping (button click counters, one-shot command timers, the “is this the first frame in this state” flag) and logs the transition to Serial. runState() then does whatever that state needs to do every frame: send the right command bytes, check elapsed time, and decide whether it’s time to move on.

Gestures, not menus

Remember — the only physical input in ACCEL mode is a single screen button (BtnA). Every gesture has to be encoded in click count and timing:

  • Single click in Flying toggles yaw on/off.
  • Double click in Idle arms and takes off (if WiFi is connected); double click in Flying starts landing.
  • Triple click, from any state, is an immediate emergency stop.
  • Press-and-hold (ACCEL mode, while Flying) drives the throttle — first hold = climb, click-then-hold = descend.

All of this is decided by a small amount of state — _clickCount, _lastReleaseMs, _buttonDown — updated once per frame in handleButton(). A click only “counts” once the double-click window (DOUBLE_CLICK_MS = 1000) has elapsed without a follow-up press, which is what lets a double-click and a hold-after-click (the throttle-down gesture) coexist on the same physical button.

In BLUETOOTH mode, the same state machine is driven by handleGamepadButtons() instead — rising edges on the gamepad’s button bitmask map directly to the same transitions (A = arm, B = land, X = emergency, etc.), so runState() itself doesn’t need to know or care which input method is active.

One state machine, two drones

This is the part that ties back to Part 2. Maritaca Force 1 (the WIFI_8K_ / E58-protocol drone) needs the full Calibrating → Arming(Unlock → TakeOff) → Flying sequence. Dr.One (the FLOW-WIFI drone) auto-arms the moment it receives a single TakeOff toggle — sending it through a multi-second calibration dance would just be wrong for that hardware.

Rather than branch the state machine on “which drone is this,” the abstract DroneProtocolBase interface exposes a single capability flag:

// FlightController::handleDoubleClick()
enterState(_deps.drone.supportsArmSequence()
           ? FlightState::Calibrating   // Maritaca Force 1
           : FlightState::Flying);      // Dr.One — auto-arms on TakeOff

and enterState() fires the one-shot TakeOff command itself when that flag is false:

if (s == FlightState::Flying) {
    _accel.begin();
    _gamepad.begin();
    if (!_deps.drone.supportsArmSequence()) {
        _oneShotCmd   = DroneCmd::TakeOff;
        _oneShotUntil = millis() + 1000;
    }
}

Everything else — gesture handling, throttle hold, the altitude-hold throttle logic, the landing and emergency paths — is shared. Swapping drones at boot is just a matter of which concrete DroneProtocolBase implementation gets injected into FlightController‘s constructor; WifiManager::scanForFirst() figures out which SSID is visible and the rest follows automatically.

Safety net: WiFi loss means emergency stop

Every call to runState() starts with one check, before the big switch statement:

if (!wifiOk && _state != FlightState::Idle && _state != FlightState::Emergency) {
    Serial.println("[Flight] WiFi lost — emergency stop");
    enterState(FlightState::Emergency);
    return;
}

If the AtomS3 ever drops its connection to the drone’s access point while armed, calibrating, flying, or landing, the very next frame forces an emergency stop — regardless of what gesture or gamepad input is happening. The same idea applies to losing the Bluetooth gamepad mid-flight in BLUETOOTH mode. Given that this whole project is one step away from “small spinning blades a meter from your face,” this single guard clause is arguably the most important seven lines in the firmware.

What’s next

With the skeleton in place — HAL, non-blocking loop, and a drone-agnostic state machine — the next two articles get to the fun part: turning physical input into flight commands. Part 4 covers the accelerometer/tilt control path (and the altitude-hold throttle gesture that took some real debugging to get right), and Part 5 covers building a BLE HID host from scratch to support a Bluetooth gamepad.

As always, the full source is on GitHub: github.com/popolony2k/maritaca-e88-controller. You can also see Maritaca Force 1 in action on the project’s YouTube playlist.

Enjoy
[]’s
PopolonY2k

Building a Custom Drone Controller from Scratch — Part 2: Reverse Engineering the UDP Protocol

In Part 1 we introduced the project: a custom WiFi flight controller for Eachine E88/E58 toy drones running on an M5Stack AtomS3. But before writing a single line of firmware, there was a much bigger problem to solve — nobody publishes the protocol these drones speak. No SDK, no documentation, no public spec. If you want to control one of these drones from your own hardware, you have to figure out the protocol yourself.

This is the story of how that happened — entirely through packet capture analysis.

The Tool: PCAPdroid

The drone exposes its own WiFi access point. Its official Android app — the kind of generic white-label app that ships with these E88/E58 clones, often named something like WIFI_CAM or KY UFO — connects to that access point and talks to the drone over plain UDP, no encryption, no authentication. That makes it a perfect target for packet capture.

Using PCAPdroid (a packet capture app for Android that doesn’t require root), it’s possible to record every packet exchanged between the phone and the drone while flying it normally with the official app. The result is a .pcap file that can be opened in Wireshark for analysis.

This single technique — capture with the phone, analyze with Wireshark — was enough to fully reverse-engineer two completely different drone protocols, described below and in Part 6 of this series.

Step 1: Finding the Control Channel

The first question is always: which port is the drone listening on? Standard E58/E88 drones are documented (informally, around hobbyist forums) to use UDP port 50000 or 7099. A quick port scan against this drone showed those ports closed. The actual control channel turned out to be UDP port 8090 — non-standard, and only discoverable by capturing real traffic from the official app.

Filtering the capture by the drone’s IP address (192.168.4.153) and watching for periodic, fixed-size UDP packets immediately revealed the pattern: 8-byte packets sent roughly 25 times per second while the joystick was active.

Step 2: Decoding the Packet Format

Looking at several consecutive packets side by side made the structure obvious almost immediately — most bytes stayed at a constant value (0x80, the “neutral” position for a joystick axis), while one byte changed smoothly as the throttle stick moved:

66 80 80 80 80 00 00 99   ← all axes neutral
66 80 80 26 80 00 a6 99   ← throttle increasing
66 80 80 52 80 00 d2 99   ← throttle higher

From this, the 8-byte structure became clear:

[ 0x66 | Roll | Pitch | Throttle | Yaw | Cmd | XOR | 0x99 ]
  • Byte 0 — fixed header 0x66
  • Bytes 1–4 — Roll, Pitch, Throttle, Yaw, each centered at 0x80 (128)
  • Byte 5 — command flags (take-off, land, flip, etc.)
  • Byte 6 — checksum
  • Byte 7 — fixed footer 0x99

The checksum was the easiest part to confirm — byte 6 is simply the XOR of the four axis bytes. Take the second sample above: 0x80 ^ 0x80 ^ 0x26 ^ 0x80 = 0xA6. Matches exactly.

Step 3: The Missing Piece — App Mode Activation

Here’s where things got interesting. Sending these exact 8-byte packets to port 8090 did nothing. The drone simply ignored them.

The capture held the answer. Right after connecting, before any control packet appeared on port 8090, the app sent two tiny packets to a completely different port — UDP 8080, which is also the drone’s video streaming port:

42 76   → sent on connect: switches drone to WiFi/app control mode
42 77   → sent on disconnect: returns drone to 2.4 GHz RF controller mode

The drone normally boots in 2.4 GHz RF mode (controlled by its physical remote) and completely ignores WiFi commands until this two-byte handshake arrives. Once 42 76 is sent, the drone switches modes, the video stream starts instantly, and port 8090 starts accepting control packets. This single discovery — a two-byte “magic packet” hidden in the connection sequence — was the missing 10% that made the whole protocol work.

Step 4: The Keepalive Packet

One more detail surfaced while analyzing idle periods in the capture — moments where the joystick wasn’t being touched. Instead of going silent, the app kept sending a different 8-byte packet roughly every 790 ms:

AA 80 80 00 80 00 80 55

Notice the header and footer: 0xAA and 0x55 are the bitwise complements of 0x66 and 0x99 — a nice touch of symmetry from whoever designed this protocol. This keepalive almost certainly prevents the drone from timing out the connection and triggering a failsafe landing.

Step 5: Mapping the Command Flags

The command byte (byte 5) only changes when the user presses a button in the app — take off, land, flip, calibrate, etc. By isolating captures around each button press and comparing the command byte before and after, each flag could be matched to its function:

BitValueFunction
00x01Auto take-off
10x02Land
20x04Emergency stop
30x08360° flip
40x10Headless mode
50x20Lock
60x40Unlock motors
70x80Calibrate gyro

The Complete Picture

Putting it all together, the full control sequence for this drone (which we identified as a WIFI_8K_ variant — confirmed via its SSID WIFI_8K_Wf48702) looks like this:

  1. Connect to the drone’s WiFi access point
  2. Send 42 76 to UDP port 8080 — switches the drone into app control mode
  3. Send 8-byte control packets to UDP port 8090 at ~25 Hz while flying
  4. Send the AA…55 keepalive every ~790 ms when the stick is idle
  5. Send 42 77 to UDP port 8080 on disconnect — returns the drone to RF mode

None of this is documented anywhere. It was all extracted, byte by byte, from real traffic captured between a phone and a drone.

Why This Matters

This kind of analysis isn’t just useful for toy drones. The same workflow — capture real traffic, isolate patterns, correlate user actions with byte changes — applies to any closed proprietary protocol: IoT devices, smart home gadgets, obscure consumer electronics. PCAPdroid plus Wireshark plus patience is a surprisingly powerful combination.

The full implementation of this protocol, including the packet encoder, checksum logic, and state machine that drives it, lives in the project’s source code: github.com/popolony2k/maritaca-e88-controller.

What’s Next

In Part 3, we’ll move from protocol to firmware — the architecture decisions behind the AtomS3 codebase: the hardware abstraction layer, the non-blocking control loop, and the flight state machine that turns raw protocol bytes into a real, controllable drone.

Enjoy
[]’s
PopolonY2k