Air Defender - Air Quality LED Arcade Game

by Donald Bell in Circuits > Arduino

81 Views, 0 Favorites, 0 Comments

Air Defender - Air Quality LED Arcade Game

AD_Hero_Image.png
IMG_4555.JPG
trimmedrobotacvert-ezgif.com-optimize.gif

Air Defender is an interactive air-quality game built for two ESP32-based boards that talk to each other wirelessly.

One board runs the game software (the console) and outputs the LED visuals on 3 LED strips, while the other board acts as the wireless controller and user interface. Together they turn real-world air pollution data into a fast-paced, educational arcade experience.

I made this game as a fun way to visualize an issue that is essentially invisible. In most cases, the quality of the air we breathe is impossible to see, smell or taste. Websites such as waqi.info (used here) scientifically measure and publish the real-time results of contamination measurements from around the world.

We tend not to think about air quality until it becomes a problem. This game offers an entertaining way to provide an awareness of air quality levels locally and around the world. By making it a battle game players have a sense of agency in combating contamination.

Supplies

I Made an Air Pollution LED Arcade Game

Console hardware

  1. M5Stack Atom S3 Lite (or similar, this is just what I had available)
  2. Three Neopixel LED strips for the pollutant lanes (3 strips of 100 pixels)
  3. Aluminum channel and diffuser to support the LED strip
  4. 5v USB-C power supply (or battery bank)

Controller hardware

  1. Adafruit QT Py ESP32-S3 (or similar, this is just what I had available)
  2. Three 60mm arcade buttons, illuminated
  3. 20x4 I2C LCD display (be sure to get one that's already I2C compatible)
  4. MAX98357 Class D Audio Amplifier Board with I2C
  5. Small speaker
  6. Stereo line output 3.5mm jack (optional)
  7. DC-DC power converter (12v-5v)
  8. DC barrel jack
  9. Screws (M3 & M2.5)

Play the Online Demo

onlinegame-ezgif.com-optimize.gif

To see if you like the game enough to build it I created an online version of the game you can play in your browser right now.

3D Printing

Screenshot 2026-09-25 at 6.46.09 PM.png
Screenshot 2026-09-25 at 6.46.55 PM.png

I created the enclosures and supports for both the controller and console using Tinkercad. You can find them embedded here. Most pieces were printed in grey PETG for durability. The top panels were printed in Prusament PLA Vanilla White with a Hilbert Curve bottom layer infill pattern to give it an opal texture.

Those Nerf dart style interlocking support columns were inspired by John Park at Adafruit and his design for this 3D printed arcade controller. In my opinion they do an amazing job providing stiffness, especially for a box like this that is made to be slammed on.



Build the Controller

IMG_5371.JPG
IMG_4286.JPG

The controller is made up of three of these 3D printed pentagon modules, plus the screen enclosure.

I could have been smart and designed in a way to easily attach these together or pass cables easily between the modules. But instead i fastened it all together with screws and glue. After everything was joined together I used a spade bit to clumsily create holes in the walls of the enclosure to pass cables between the different compartments.

All the mess is hidden by the panels. Since the bottom panels face down, I just printed them in grey PETG like the outer shell and the used common wood screws and some silicone glue (E6000 or similar) to attach them. It's unlikely you'll ever need to remove these panels since everything can be accessed through the top panels.

The top panels were made to be nicer looking. I attached them with stainless steel M2.5 hex socket cap head screws. The trouble is, the accuracy of the 3D printed mounting holes in the shell is bound to be inconsistent. So you may need to cheat the holes a little or glue in some channel reinforcement to get the screw threads to bite.

Controller Electronics

Screenshot 2026-09-24 at 9.28.31 AM.png
IMG_5373.JPG

It looks like a spaghetti monster, but it's really not so bad. There are only a few components here. In hindsight I probably could have spaced them better throughout the enclosure instead of cramming so many into the central button box. But for prototyping it made sense to have everything centralized for making adjustments.

Most of the room is taken up by the big ol' arcade buttons I used. They're extra deep because they include an LED holder to light them up. A 12v LED came preinstalled, which led me to believe I needed to have two voltages in this system -- 12v for the buttons and 5v for the microcontroller. In hindsight I should have just replaced the LEDs with something 5v compatible and run the whole system on one voltage.

For better or worse, I'm using a 12v power input here which runs out to the button LEDs before being stepped down to 5v with a DC-DC converter. That 5v goes to the microcontroller, the amplifier board, and the LCD. The microcontroller is powered over the USB connection using the USB output on the DC-DC converter I used. It's overkill but it probably kept me from frying the board by powering it directly to the pins.

The amplifier board runs sound out to a small speaker which is fine for quiet spaces (loud enough to be annoying, actually). But for louder spaces like classrooms and events the little speaker is easily drowned out. So, I added a panel-mount minijack output that can connect to a speaker system if needed. I just wired that into the same amplified output for the speaker. The sound quality isn't awesome, but it's good enough for the vintage arcade synthesized bleeps and bloops made by the game.

Build the Console

IMG_4516.JPG
IMG_4568.JPG

I call it the "console" because this is technically where the game lives, but the main structure here is really to support our display. It's a display that hangs on a wall instead of sitting on the ground, which is a little unusual but adds to the novelty.

All the electronics just live on the high shelf on the top, zip-tied down and out of view. I included a pass-through hole in this top piece for a power cable or USB cable to be routed down in a deliberate looking way. I also included loop holes behind each channel that you can use to loop zip-ties for securing the electronics on top.

I designed the part that meets the wall to be tapered to a point (like a French cleat), allowing it to easily hang on a couple screws. The space I designed this for already had a gallery-style cleat system for hanging artwork, so I made this to drop in (which it sorta does).

The smaller, bottom piece is really just there to wrangle all the ends of the channel together and provide a nice rest against the wall.

To make the whole system extra secure I recommend seating the aluminum channel in each piece and drilling screw holes you can use to secure the channel to the holders with screws.

Transporting this whole thing is super awkward due to the height of the aluminum channel, so I did what I could to allow this console/display system to come apart and be easily reassembled. With that in mind, I recommend keeping the wiring for this system detachable too, especially the wiring between the LED strips and the microcontroller.

The LED strip I used was adhesive-backed, so I just used that adhesive to join the strip to the channel and I haven't had any issues so far.

To dress up the top of the game I made a 3D printed sign and attached it with double-sided tape. You can swap colors at different layer heights to create a multi-color effect.

Console Electronics

Screenshot 2026-09-28 at 7.46.51 AM.png
IMG_4566.JPG
IMG_4565.JPG

This is a pretty simple circuit for lighting three stands of Neopixel strip from a microcontroller.

You have 5v power coming in, smoothed out by a 1000uf capacitor. That power is going out to both the strips and the 5v input of the microcontroller. Ideally these should be on separate, regulated lines, but it works this way so I left it alone.

The data input lines from each of the three strips are being fed by their own data pins on the microcontroller. I could have simplified things by having one data pin drive everything and having the strips run on a shared data line, but it would have complicated the physical design of how I have the strips laid out.

I used screw terminals for all the power wires coming from the strip so that they could easily detach for transportation. I put Dupont connectors on the data wires so they could push onto the microcontroller data pins.

For 5v input I used (and wouldn't necessarily recommend) a USB-C PD adapter that you can set to receive specific voltage and wattage profiles. It worked but it's not very breadboard friendly. To adapt it to the breadboard configuration you see here I had to cut the copper on the breadboard power rails so that the positive rail could share both positive and negative power (negative to the left, positive to the right). Oh, the crimes we commit while prototyping! You can do better.

Code

Screenshot 2026-10-04 at 8.25.16 AM.png

Air Defender's firmware is built with PlatformIO inside Visual Studio Code (VS Code), rather than the classic Arduino IDE. I'll be real with you, my main reason for using this setup is that it's super easy to integrate AI coding agents (GitHub Copilot, Claude Code) to help code, adapt, and troubleshoot projects. But it's also true dual-board projects like this are much easier to juggle and track in PlatformIO.

If you've never used any of these, don't worry — here's what you need and why it's set up this way.

Getting set up

1. Install VS Code (https://code.visualstudio.com/) (free).

2. Open VS Code, go to the Extensions panel (the icon that looks like four squares in the left sidebar), search for PlatformIO IDE, and install it. Give it a minute after installing — it downloads some tooling in the background the first time.

3. Download air-defender-firmware-v1.0.zip (https://github.com/donaldbell/Air-Defender/releases/download/v1.0/air-defender-firmware-v1.0.zip) and unzip it somewhere on your computer.

4. In VS Code, use File > Open Folder and select the unzipped air-defender-firmware folder (not a file inside it — the folder itself).

As soon as you open that folder, PlatformIO will recognize it as a PlatformIO project automatically. That's because of a file sitting right at the top of the folder called platformio.ini — think of it as the recipe card that tells PlatformIO what hardware this project targets, what libraries it needs, and how to build it. PlatformIO always looks for this file at the root of whatever folder you open, which is why it has to stay there rather than tucked inside a subfolder.

Why this is a "two boards, one project" setup

Most PlatformIO tutorials assume you're building firmware for a single board. Air Defender is built for two — the console unit (the board that runs the game and drives the LED display) and the controller unit (the handheld board with the LCD and buttons). Rather than keeping two totally separate, duplicate projects, this repo uses one shared project with two build "environments", defined in that same platformio.ini file: one named console, one named controller.

When you open the project, you'll see an environment switcher at the bottom of the VS Code window (the PlatformIO toolbar). Pick console and hit the checkmark (Build) or arrow (Upload) icon to compile and flash the console board; switch to controller to do the same for the handheld. Each environment automatically pulls in only the source files that particular board actually needs — so the console board doesn't end up with unused audio code it has no speaker for, and the controller doesn't get LED-driving code it has no strip to run.

Opening the folder, you'll see:

- platformio.ini — the project recipe described above. Always at the root; this is non-negotiable for PlatformIO to recognize the project.

- src/ — all the .cpp code, for both boards, living side-by-side. Some files (like console_unit.cpp and controller_unit.cpp) belong to just one board; others (like communication_protocol.cpp and status_led.cpp) are shared logic both boards use — for example, the code that lets the two boards talk to each other wirelessly needs to be identical on both ends, so it lives in one file instead of two copies that could drift out of sync.

- include/ — the header files (.h) that go with the code in src/. This is just standard C++ project structure: implementation in src/, declarations in include/.

- lib/ — reserved for any custom libraries specific to this project (currently empty aside from a placeholder file); third-party libraries like the NeoPixel and FastLED drivers are fetched automatically by PlatformIO based on what's listed in platformio.ini, so you won't see them cluttering this folder.

- .vscode/ — small config files that tell VS Code to recommend the PlatformIO extension if you don't already have it, and to show a few useful build shortcuts.

The short version: it looks like one project because it is one project — just one that happens to build two different firmware images, for two different boards, out of a shared pool of source files.

Quick note on the air quality data: Early on I had my own WAQI API token just sitting in the code, which — oops — meant it was sitting right there in the public GitHub repo for anyone to grab. Fixed that, so the firmware you download from the release zip now has a placeholder (YOUR_WAQI_TOKEN_HERE) where that token used to be. You'll need to grab your own — it's free, takes about thirty seconds at aqicn.org/data-platform/token (https://aqicn.org/data-platform/token/) — and paste it into API_KEY near the top of include/game_config.h. If you skip this step, the game still runs fine, it just uses the baked-in fallback pollution numbers instead of pulling live data for each city.

Using a Different ESP32-S3 Board

boardchange2-ezgif.com-optimize.gif
Screenshot 2026-10-04 at 8.35.06 AM.png

Don't have the exact M5Stack AtomS3 or Adafruit QT Py ESP32-S3 boards? No problem. You can usually swap in a different ESP32-S3 board without starting from scratch.

Open up platformio.ini and look for the two sections near the top, [env:console] and [env:controller]. Inside each one is a single line starting with board = — that line is what tells PlatformIO exactly which board you're using.

The easiest way to change it: click right into that line, after board = , and start typing your board's name. PlatformIO's VS Code extension will pop up a searchable list of matching boards as you type — just pick yours from the list, no need to hunt down an ID number by hand. If the autocomplete doesn't show anything (it depends on having the PlatformIO extension's IntelliSense fully loaded), you can also browse the full board catalog by clicking the little alien-head icon in the left sidebar to open "PlatformIO Home," then searching the Boards tab there to find your board's exact name.

One catch worth knowing before you swap: changing the board line updates things like flash size and chip variant automatically, but it won't remap which physical pins the code uses. The pin numbers for the LEDs, buttons, and LCD are set in include/game_config.h, wired specifically to how the original boards were built. If your replacement board breaks out its pins differently, you'll need to update those pin definitions to match your wiring — the board swap gets you compiling for the right chip, but the pin map is still on you.

Wireless Logic Between Components

Screenshot 2026-10-04 at 8.59.12 AM.png
controller-ezgif.com-optimize.gif

The fact that the two pieces of this project talk to each other wirelessly is probably the coolest technical part of the whole build. To make this happen I'm using a wireless protocol called ESP-NOW which is unique to the ESP-32 ecosystem of microcontrollers.

Because the game is so tall, I wanted a way for the controller and the LED strips have a good distance between each other. But because I made this project for an event, I wanted to be sure I wasn't creating a tripping hazard by wiring the controller directly to the game. Making the two components wireless solved a real problem for me here.

A quick explanation of ESP-NOW is that it's wireless communication between devices that uses the Wi-Fi radio chip. There's no router, no network, no connecting to anything — it's basically two chips just yelling directly at each other over the air. It's also super fast, which matters when you need a button press to register instantly instead of after some network handshake.

The catch is you have to introduce the two boards to each other first. Every ESP32 chip comes with its own radio address burned in at the factory, kind of like a serial number, and ESP-NOW messages go to one specific address, not just out into the void. So I had to flash each board once, open the serial monitor, let it print its own address, and then copy that number into the other board's code by hand. Felt a little clunky the first time I did it, but once it's set it just works, and it doesn't need any Wi-Fi network to exist anywhere nearby.

Messages go both directions constantly, not just one way. Button mashes on the controller ("fire blue," "charge the cannon," "I picked this city") fly over to the console. Meanwhile the console is firing stuff back the other way — play this sound, here's the updated score, show the next slide, you won / you lost. So really it's less "controller sends commands" and more "both boards are chattering back and forth the whole game."

(Side note tying back to the board-swap part of the Code section before this — if you use a different ESP32-S3 board, you've still got to redo this MAC-address pairing step. That part has nothing to do with which board you pick; every chip gets its own unique address no matter what.)

Interpreting Air Quality Data

Screenshot 2026-10-04 at 9.08.36 AM.png

Without real-world air quality data the whole concept of this game kinda falls apart. It also adds an element of surprise to the game because air quality measurements are always changing, which results in shifting levels of difficulty day to day.

However, presenting a useful and truthful snapshot of a city's air quality data turned out to be messier than I assumed. I had to make some judgment calls.

First surprise: the air quality API I'm using (WAQI) doesn't actually hand you a raw pollution number. It gives you each pollutant's own "AQI sub-index" — a 0-500 score already squished through EPA's non-linear scale. Problem is, an AQI of 100 for ozone and an AQI of 100 for PM2.5 don't represent the same amount of actual pollution — they're each on their own private scale. So I had Claude run EPA's conversion formula backwards to pull the real concentration number back out, just so the three numbers would actually mean something comparable, and line up with the WHO guideline numbers I'm using elsewhere in the game.

Ozone turned out to be the messiest of the three, which I think is what I half-remembered when I said it gets special treatment. Ozone isn't pollution that just sits around in a city — it's basically cooked up fresh by sunlight hitting car exhaust, so it's near zero at 3am and can spike hard by mid-afternoon. That means if the game happens to grab data at the wrong moment, a genuinely smoggy city could show up looking totally clean, just because nobody's turned the sun on yet. So instead of trusting whatever instantaneous number comes back, I grab WAQI's daily average for ozone when it's available — same trick I use for PM2.5, same problem. NO2 doesn't get that treatment, not because I didn't want it to — WAQI just doesn't publish a daily average feed for NO2, so it's stuck using whatever single reading comes back, for better or worse.

Even after all that, I didn't trust a live fetch to be the final word on its own. Every live number — ozone, PM2.5, NO2, averaged or not — gets blended 50/50 with a fallback number I set myself based on each city's known, typical pollution level. That way a lucky clean-air day in a famously smoggy city doesn't suddenly make it feel like the easy level, and a freak spike in a normally clean one doesn't make it feel broken. Real conditions still nudge the number around, just not enough to override what the city's actually known for.

I would not use this game as a scientifically accurate measure of a city's real-world, real-time air quality. It's making use of real data and updating the level difficulty based on that data. But it's also weighting and averaging that data in an effort to tell what I hope is a truer, more consistent story about each city's struggles with pollution.

Loading AQI Data

Screenshot 2026-09-28 at 7.56.14 AM.png
IMG_4551.JPG

So how and when does the global AQI data actually get updated in the game?

Well, with the game connected to reliable Wi-Fi you could theoretically have it pull down a fresh snapshot every minute or so.

But the reality is that the AQI data doesn't shift notably minute to minute. Plus, frequent updating would eat up limited processing resources and lead to some glitches. Because not only does the data need to get pulled over to the controller where the AQI numbers are updated on the LCD screen, but it also is sent over to the controller where it's reflected in the LED enemy levels. It doesn't take long, but it would slow things down and lead to errors if it refreshed automatically.

And then there's the practical concern of Wi-Fi. Do I want to design this game so that it needs Wi-Fi access to function? How do I update the Wi-Fi address in the code each time I move it to a new location?

My solution is to manually push updates to the system from your phone. You can do this as often as you like, but once a day is probably enough.

Here's how it's done:

  1. Have the console/display already powered on.
  2. Power on or restart the controller.
  3. Press and hold the Blue button for a few seconds while the controller boots up. This puts the ESP32 board in controller into an access point (AP) mode where it acts as it's own Wi-Fi network that you can connect to. The network is called AQI_Game. The password is "environmental".
  4. Once connected to the network type 192.168.4.1 into your phone's web browser. It should take you to City AQI Data table shown in the image above.
  5. Press the purple "Load All Cities" button.
  6. Because your phone will need to connect to the internet to pull down this data, turn off your phone's Wi-Fi connection for a few seconds to force it to use mobile data.
  7. Turn your phone's Wi-Fi back on after only just a few seconds in mobile data mode. Hopefully it will automatically reconnect to the controller's AP network and you'll see new AQI numbers populated in the table.
  8. With the new data in the table, tap the green "Send All Cities to Console" button. This will update the AQI information on the console to match what's now on the controller.
  9. Disconnect your phone from the controller's AP network and maybe reboot the controller for good measure.

I know that sounds like a lot, but really it just takes under a minute to do after you have the process down. The biggest upshot to pushing the data manually like this is that the system never has to connect to a Wi-Fi network or get configured to use one. The system is the Wi-Fi network, and you push data to it when it's convenient for you.

Language Support

Screenshot 2026-09-28 at 7.56.51 AM.png
Screenshot 2026-10-04 at 4.57.07 PM.png

While you're in AP mode updating AQI data you also have the opportunity to change the language used on the LCD.

I needed the instruction in Catalan for the event I built this for. While I was at it I threw in an option for Spanish figuring it might be useful.

I had to create some special characters for the LCD to get the Catalan accents correct. If you add any additional language support just look out for special character support in the LCD library.

Adding Extras

IMG_4770-ezgif.com-optimize.gif
IMG_4653.JPG
IMG_4746.JPG
IMG_4747.JPG
IMG_4749.JPG
IMG_4750.JPG

The event I made this game for was held at El Parc de l'Espanya Industrial in Barcelona, which features these interesting towers. I thought it would be fun to incorporate them as a decorative part of the game. I made thin, 3D printed, translucent versions of the towers and clipped them to the LED channels.

With careful placement it looks like the anti-pollution shots are being fired from the towers. I had to turn the towers upside down to make it look right, but I still think it's a neat detail for this occasion.

I didn't include the tower designs here, but I did include the clips. They're in the Wall Mount LED Profile Holder design.

You can see in the photos here how they just glue on the back of whatever you're looking to attach to the aluminum channel.

Originally I made these clips to add labels to each column (PM2.5, NO2, Ozone) but I couldn't get it to look right. Maybe you can.

Final Thoughts

Designing games is hard, but fun. Designing games that are also educational and tied to real-world data is especially challenging. Next time I make a game it's just going to be something fun.

I also wouldn't make a tall, skinny game like this again. It's awkward to transport, and terrible to try and capture on camera.

That said, I learned a ton during this project and it was a great opportunity to collaborate with the team at FAB Casa del Mig, who inspired many of the ideas and game mechanics of Air Defender and gave me a chance to showcase it at their event.