Introduction: Building the Cheapest 3-Channel RC Plane

About: We’re Abhijithu and Nakshathra, siblings passionate about DIY. She enjoys art, crafts, and sculpting, while I focus on inventing and creating unique projects.Our DIY channel is founded on the belief that money…

I’ve always wanted to build an RC plane, but as many know, while constructing the frame is cheap, the real cost is in the electronics—especially the transmitter and receiver, which make up a significant portion of the expense. So to create an RC plane on a super-low budget, I focused on reducing costs for the transmitter and receiver.

After doing some research, I found a few videos demonstrating how to control RC planes with ESP32 boards using their Wi-Fi capabilities. Most examples were simple paper planes with two coreless motors to control pitch and direction, but I wanted to take it a step further by adding actual control surfaces for pitch and yaw control.

Now we know that the ESP32 could work as both a transmitter and receiver, so with some coding adjustments, we could customize it to our needs. Luckily, I had a Xiao ESP32-C3 board, which I set up as the receiver, while a webpage on my phone served as the transmitter through the C3’s Wi-Fi.

To handle pitch and yaw, I used magnetic actuators [coils salvaged from a clock mechanism]. To keep costs even lower, I constructed the plane’s frame from thermocol boards salvaged from packaging materials. If you’re planning to make one, I’d recommend using Depron sheets [or sheets specifically for RC planes] instead—they’re stronger and more durable, as thermocol can be fragile and couldn't survive crashes.

For thrust, I used a 720 coreless motor with a 55mm propeller, controlled by an SI2302 N-channel MOSFET. The complete circuit diagram and code are provided in the following steps. With all these elements, I was able to build a 3-channel RC plane within a $12 USD budget.

So, for everyone who’s wanted to build an RC plane but felt held back by the cost, here’s my gift to you! I’ve lowered the budget bar enough that you, too, can make your own RC plane on a shoestring budget.

Supplies

Electronics parts

  1. 1 x Xiao esp32 C3 [ ₹437 ]
  2. 1 x DRV 8833 [ ₹65 ]
  3. 1 x 3.7V 360 mAh 30C [ ₹250 ]
  4. 1 x SI2302 N channel Mosfet [ ₹1.9 ]
  5. 1 x 10K SMD resistor [ ₹0.5 ]
  6. 1 x SS14 Schottky diode [ ₹0.88 ]
  7. 1 x 720 Coreless motor [ ₹50 ]
  8. Coil for the magnetic actuator ( from a clock mechanism )

RC Plane parts

  1. 1 x 55mm propeller [ ₹7.5 ]
  2. Thermocol or Depron sheet (3mm)
  3. Glue ( I used Araldite klear epoxy) [ ₹68 ]
  4. Two side tape ( optional )
  5. PVC Card
  6. 1 x OHP sheets (100 microns) [ ₹2 ]
  7. 2 x [5x3mm] Neodymium magnet [ ₹12 ]
  8. 1 x 10mm candle [ ₹3 ]
  9. Nylon thread

Tools for the build

  1. Craft knife
  2. Emery paper (220 grit)
  3. 3mm Drill Bit (For Magnet Placement Holes)
  4. Soldering iron

If you’re planning to use thermocol sheets to build your RC plane like I did, you’ll need to set up a jig with nichrome wire to cut 3mm thin sheets from the bulk thermocol. However, if you’re using Depron sheets, you can skip this hassle entirely.

Total cost = ₹897.78 + miscellaneous expenses = ₹1000 (approximately $12 USD).

Step 1: Magnetic Actuator Part 1

We'll begin by building the actuator for our RC plane. First, I’m cutting the moving part of the actuator from a PVC card, then gluing a 3mm neodymium magnet into the pre-drilled holes.

Step 2: Magnetic Actuator Part 2

Now, we’ll be making the coil for the magnetic actuator. I’m using a 10mm candle as a guide to wind it. For a better understanding, check out the detailed video included in this step.

Step 3: Assembling the Circuit

Following the provided circuit diagram, connect and solder all components.

Step 4: Libraries to Install

Required Libraries: AsyncTCP, ESPAsyncWebServer

Before uploading the code, ensure these libraries are installed. Also, make sure to install only version 2.0.17 of the ESP32 board by Espressif Systems, as using a newer version may cause compilation issues with the code I provided in the below step.

Step 5: Uploading the Code


After installing the necessary libraries, copy the code below and upload it to the ESP32-C3 board using the Arduino IDE.

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>

// Pin definitions
const int motorAPin = D10;// PWM pin for Motor A (thrust control)
const int motorBPin1 = D5; // IN1 for Motor B (clockwise)
const int motorBPin2 = D4; // IN2 for Motor B (counterclockwise)
const int motorCPin1 = D3; // IN1 for Motor C (clockwise)
const int motorCPin2 = D2; // IN2 for Motor C (counterclockwise)

// PWM properties for Motor A (Throttle)
const int pwmChannel = 0;  // PWM channel
const int pwmFreq = 30000;  // PWM frequency
const int pwmResolution = 8; // PWM resolution (8-bit)

// Wi-Fi credentials
const char* ssid = "MISFIT-RC-PLANE"; // Name of your wifi network
const char* password = "12345678"; // Your wifi network password

AsyncWebServer server(80); // Web server on port 80
bool isArmed = false;    // Variable to track armed/disarmed state

// HTML code for the control interface (stored in flash memory)
const char controlPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
 <title>Misfit RC Plane Control</title>
 <style>
  h1 { font-size: 80px; border: thick solid #000; padding: 10px; border-radius: 10px; background-color: #fff; display: inline-block; }
  h2 { font-size: 45px; }
  body { font-family: Impact; text-align: center; background-color: #f0f0f0; }
  #scrollbar-container { width: 260px; height: 600px; border: thick solid #000; background-color: #ddd; border-radius: 30px; overflow: hidden; position: relative; margin: 0px; box-shadow: inset 0 0 50px rgba(0, 0, 0, 0.3); }
  #scrollbar-fill { width: 100%; height: 0; background-color: #8cff00 ; position: absolute; bottom: 0; z-index: 0; }
  #scrollbar-handle { width: 260px; height: 150px; background-color: #888; position: absolute; bottom: 0; border-radius: 30px; display: flex; justify-content: center; align-items: center; z-index: 1; cursor: pointer; transition: background-color 0.3s; }
  #scrollbar-handle.active { background-color: red; }
  #scroll-tab { width: 160px; height: 30px; background-color: #444; border-radius: 4px; position: absolute; top: 64px; left: 50%; transform: translateX(-50%); cursor: pointer; }
  .button { padding: 70px 120px; margin: 10px; font-size: 60px; font-weight: bold; cursor: pointer; border: thick solid #000; border-radius: 12px; background-color: #818181; color: white; transition: background-color 0.3s; user-select: none; }
  .button:active { background-color: #ff0000; }
  .controls, .cross, .horizontal, .vertical-buttons { display: flex; justify-content: center; align-items: center; }
  .vertical-buttons { flex-direction: column; }
  .cross { flex-direction: column; }
  .horizontal { margin: 0; }
  #armButton { background-color: #28a745; color: white; transition: background-color 0.3s; user-select: none; margin-left: 150px; width: 260px; height: 150px; display: flex; justify-content: center; align-items: center; font-size: 45px; font-weight: bold; border-radius: 12px;}
  #armButton.active { background-color: red; }
  #motorAValue { font-size: 45px; font-weight: bold; margin-top: 0px; color: #333; }
 </style>
</head>
<body>
 <h1>Misfit - RC - Plane</h1>
 <div class="controls">
  <div class="vertical">
   <h2>THRUST</h2>
   <div id="scrollbar-container">
    <div id="scrollbar-fill"></div>
    <div id="scrollbar-handle"><div id="scroll-tab"></div></div>
   </div>
   <p id="motorAValue">Speed: 0%</p>
  </div>
  <div class="arm-container">
   <button class="button" id="armButton" onclick="toggleArm()">Arm</button>
  </div>
 </div>
 <div class="cross">
  <h2>DIRECTIONS</h2>
  <div class="vertical-buttons">
   <button class="button motor-btn" onmousedown="updateMotorB(1)" onmouseup="stopMotorB()" ontouchstart="updateMotorB(1)" ontouchend="stopMotorB()">&#8679;</button>
   <div class="horizontal">
    <button class="button motor-btn" onmousedown="updateMotorC(0)" onmouseup="stopMotorC()" ontouchstart="updateMotorC(0)" ontouchend="stopMotorC()">&#8678;</button>
    <button class="button motor-btn" onmousedown="updateMotorC(1)" onmouseup="stopMotorC()" ontouchstart="updateMotorC(1)" ontouchend="stopMotorC()">&#8680;</button>
   </div>
   <button class="button motor-btn" onmousedown="updateMotorB(0)" onmouseup="stopMotorB()" ontouchstart="updateMotorB(0)" ontouchend="stopMotorB()">&#8681;</button>
  </div>
 </div>
 <script>
  let armed = false, handle = document.getElementById('scrollbar-handle'), container = document.getElementById('scrollbar-container'),
    motorAValueDisplay = document.getElementById('motorAValue'), fill = document.getElementById('scrollbar-fill'), maxScroll = container.clientHeight - handle.clientHeight;

  handle.style.top = maxScroll + 'px';
  motorAValueDisplay.innerText = '0%';

  function updateMotorA(value) {
   motorAValueDisplay.innerText = `${Math.round((value / 255) * 100)}%`;
   fetch(`/motorA?speed=${value}`).catch(console.error);
   fill.style.height = `${(value / 255) * container.clientHeight}px`;
  }

  handle.addEventListener('mousedown', event => handleDrag(event, 'mousemove', 'mouseup'));
  handle.addEventListener('touchstart', event => handleDrag(event, 'touchmove', 'touchend'));

  function handleDrag(event, moveEvent, endEvent) {
   event.preventDefault();
   handle.classList.add('active');
   const initialY = event.clientY || event.touches[0].clientY, initialTop = parseInt(handle.style.top, 10);

   function onMove(e) {
    const newY = (e.clientY || e.touches[0].clientY) - initialY + initialTop;
    handle.style.top = `${Math.max(0, Math.min(newY, maxScroll))}px`;
    updateMotorA(Math.round(255 - (parseInt(handle.style.top) / maxScroll) * 255));
   }

   function onEnd() {
    handle.classList.remove('active');
    window.removeEventListener(moveEvent, onMove);
    window.removeEventListener(endEvent, onEnd);
   }

   window.addEventListener(moveEvent, onMove);
   window.addEventListener(endEvent, onEnd);
  }

  function toggleArm() {
   armed = !armed;
   document.getElementById('armButton').innerText = armed ? 'Disarm' : 'Arm';
   document.getElementById('armButton').classList.toggle('active');
   fetch(`/toggleArm?state=${armed ? 'true' : 'false'}`).catch(console.error);
  }

  function updateMotorB(direction) { fetch(`/motorB?dir=${direction}`).catch(console.error); }
  function stopMotorB() { fetch('/motorB?stop=1').catch(console.error); }
  function updateMotorC(direction) { fetch(`/motorC?dir=${direction}`).catch(console.error); }
  function stopMotorC() { fetch('/motorC?stop=1').catch(console.error); }
 </script>
</body>
</html>
)rawliteral";

void stopAllMotors() {
 // Function to stop all motors
 ledcWrite(pwmChannel, 0); // Stop Motor A (Throttle)
 digitalWrite(motorBPin1, LOW);
 digitalWrite(motorBPin2, LOW); // Stop Motor B
 digitalWrite(motorCPin1, LOW);
 digitalWrite(motorCPin2, LOW); // Stop Motor C
}

void setup() {
 Serial.begin(115200);

 // Motor control pin setup
 pinMode(motorBPin1, OUTPUT);
 pinMode(motorBPin2, OUTPUT);
 pinMode(motorCPin1, OUTPUT);
 pinMode(motorCPin2, OUTPUT);

 // PWM setup for Motor A (Throttle control)
 ledcSetup(pwmChannel, pwmFreq, pwmResolution);
 ledcAttachPin(motorAPin, pwmChannel);

 // Wi-Fi Access Point setup
 WiFi.softAP(ssid, password);
 Serial.println();
 Serial.print("Access Point IP Address: ");
 Serial.println(WiFi.softAPIP());

 // Serve the control page
 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
  request->send_P(200, "text/html", controlPage);
 });

 // Motor A control (Throttle)
 server.on("/motorA", HTTP_GET, [](AsyncWebServerRequest *request) {
  if (isArmed && request->hasParam("speed")) { // Only update speed if armed
   int motorASpeed = request->getParam("speed")->value().toInt();
   ledcWrite(pwmChannel, motorASpeed);
   Serial.println("Motor A Speed: " + String(motorASpeed));
   request->send(200, "text/plain", "Motor A Updated");
  } else {
   stopAllMotors(); // Ensure safety by stopping all motors if disarmed
   request->send(200, "text/plain", "Plane is disarmed. Motor A not updated.");
  }
 });

  // Motor B (pitch) control
 server.on("/motorB", HTTP_GET, [](AsyncWebServerRequest *request) {
  if (isArmed && request->hasParam("dir")) {
   String direction = request->getParam("dir")->value();
   if (direction == "1") {
    digitalWrite(motorBPin1, HIGH); // Set Motor B to move clockwise
    digitalWrite(motorBPin2, LOW);
    Serial.println("Motor B: Moving Clockwise");
   } else {
    digitalWrite(motorBPin1, LOW); // Set Motor B to move counterclockwise
    digitalWrite(motorBPin2, HIGH);
    Serial.println("Motor B: Moving Counterclockwise");
   }
  } else if (request->hasParam("stop")) {
   digitalWrite(motorBPin1, LOW);
   digitalWrite(motorBPin2, LOW);
   Serial.println("Motor B Stopped");
  }
  request->send(200, "text/plain", "OK");
 });

 // Motor C (yaw) control
 server.on("/motorC", HTTP_GET, [](AsyncWebServerRequest *request) {
  if (isArmed && request->hasParam("dir")) {
   String direction = request->getParam("dir")->value();
   if (direction == "1") {
    digitalWrite(motorCPin1, HIGH); // Set Motor C to move clockwise
    digitalWrite(motorCPin2, LOW);
    Serial.println("Motor C: Moving Clockwise");
   } else {
    digitalWrite(motorCPin1, LOW); // Set Motor C to move counterclockwise
    digitalWrite(motorCPin2, HIGH);
    Serial.println("Motor C: Moving Counterclockwise");
   }
  } else if (request->hasParam("stop")) {
   digitalWrite(motorCPin1, LOW);
   digitalWrite(motorCPin2, LOW);
   Serial.println("Motor C Stopped");
  }
  request->send(200, "text/plain", "OK");
 });
 // Toggle arm/disarm state
 server.on("/toggleArm", HTTP_GET, [](AsyncWebServerRequest *request) {
  if (request->hasParam("state")) {
   String state = request->getParam("state")->value();
   isArmed = (state == "true");
   Serial.println(isArmed ? "Plane Armed" : "Plane Disarmed");
   if (!isArmed) {
    stopAllMotors(); // Stop all motors when disarmed
   }
   request->send(200, "text/plain", isArmed ? "Plane Armed" : "Plane Disarmed");
  }
 });

 // Start the server
 server.begin();
}
void loop() {
}


In our project, the ESP32-C3 functions as an access point. To open the control webpage on your mobile device, first connect to the ESP32 Wi-Fi using the WIFI credentials provided in our code. Then, open a new page in Google Chrome and enter the IP address in the search bar (The IP address can be found in the Serial Monitor section in the Arduino IDE under the Tools tab). For a more detailed walkthrough, check out the video I uploaded in the introductory step.

Step 6: Cutting Out Templates

With all the electronic components ready, you can now build any RC plane design you envision. Here, I’m creating a simple Cessna-style RC plane. Using the provided PDF template, cut out the necessary parts for your plane. Since I used thermocol, I first set up a jig to slice 3mm sheets from the bulk material. I’d recommend using Depron sheets or applying a suitable coating to strengthen the thermocol, as it’s quite fragile and prone to breaking. Then, I used an emery paper to smooth the rough edges and tapered the wing trailing edges to improve it's aerodynamics.

Step 7: Shaping the Wings

A simple shaping of the wings, as shown in the GIFs, can enhance it's structural strength and provide a pseudo-airfoil effect.

Step 8: Gluing Parts Together

I used Araldite epoxy to glue the parts together, as it sets quickly and doesn’t react with foam boards. Before attaching the wings, I sanded the joining sections for a perfect fit.

Step 9: Mounting the Control Surfaces

To mount the control surfaces, I cut 1.5mm-wide strips from a 100-micron thick OHP sheet and used them to attach the rudder and elevator to the RC plane’s frame with glue as shown in the above GIFs / Images.

Step 10: Mounting the Motor

Now, I secured the 720 coreless motor to the frame using Araldite glue. Before gluing, ensure the motor is angled slightly to the right (about 2-3 degrees)[Direction of propeller- Clockwise when viewed from the back] as shown in the above pictures.

Step 11: Final Setup

With the frame complete, all that’s left is to attach the electronic components and solder the necessary connections. I attached the components to the frame using double-sided tape, though a hot glue gun is also a great option for securing them. Ensure you position the lithium-ion battery on the frame so that the plane balances slightly nose-down when held 1/4 to 1/3 of the way back from the wing’s leading edge.

Step 12: Wing Support

The final step is to reinforce the wings using nylon threads. I made small holes in the wings with a safety pin, guided by the PDF template from step 6, and threaded them as shown in the GIF above. Once the threads were set, I applied a dab of glue around each hole to keep them firmly in place and to strengthen the area, helping prevent any tearing. With that, the build is complete!. You can choose to paint it or add a protective coating if desired.

Step 13: From Scratch to Sky

This project has proven that building an RC plane doesn’t have to be expensive or out of reach. By focusing on reducing costs where it matters most—electronics and materials—I was able to create a fully functional 3-channel RC plane on a budget of just $12. Whether you're a beginner or someone looking for a low-cost DIY project, I hope this build inspires you to try your hand at making your own RC plane. With a bit of creativity, resourcefulness, and the right tools, you can bring your ideas to life without breaking the bank. The sky’s the limit—happy building!

Thank you for following along with this project! I hope you enjoyed reading about it as much as I enjoyed sharing it with you.