the man who hears a motor in everything
and cannot walk past a piece of junk
without wondering what is inside it.
The toy deserved to be built.
The DualShock 4 —
Every Button, in Rust
There are two ways to get the DS4 talking to the Pico 2W. The first — Bluetooth Classic HID directly from the CYW43439 — is the dream, and Embassy's Bluetooth stack is moving toward it. The second — WiFi UDP bridge — works today, this weekend, without waiting for anything. A small Python script on your laptop pairs to the DS4 via your PC's Bluetooth, reads the HID report at 60Hz, and forwards each report as a UDP packet to the Pico's IP on your home network. The Pico joins WiFi, binds a UDP socket, and receives controller state. No latency penalty matters for a toy — UDP over a local WiFi hop adds under 2ms.
SYSTEM ARCHITECTURE — DS4 TO MOTOR ────────────────────────────────────────────────────────────────── DualShock 4 Your Laptop Pico 2W ────────── ────────── ─────── Buttons Python script Embassy Sticks ─BT Classic─→ reads HID ─UDP/WiFi─→ parses Triggers (paired once) report struct Gyro 60 Hz drives Touchpad UDP port 4242 L298N Rumble ←───────────────────────────────────── haptic cmd (optional) DS4 HID report: 64 bytes at 60 Hz UDP payload: 64 raw bytes — no encoding, no overhead Pico receives: decodes into ControllerState struct Embassy tasks: motor_task reads state, sets PWM duty ────────────────────────────────────────────────────────────────── When Embassy BT Classic matures: DS4 ─────BT Classic HID────────────────────────────────→ Pico 2W (direct, no laptop bridge — same ControllerState struct, same tasks)
The DualShock 4 transmits a 64-byte HID report every 16 milliseconds over USB, or every 4 milliseconds over Bluetooth. Every analog value, every button state, the gyroscope, the accelerometer, and the touchpad all live in fixed byte offsets. The report is the datasheet. Learn it once and you can address every input on the controller by name.
DS4 HID REPORT — USB MODE — 64 BYTES ───────────────────────────────────────────────────────────────────────────────── BYTE FIELD RANGE NOTES ───────────────────────────────────────────────────────────────────────────── [0] Report ID 0x01 Always 0x01 in USB mode ── ANALOG STICKS ──────────────────────────────────────────────────────────── [1] Left stick X 0–255 0=full left, 128=centre, 255=full right [2] Left stick Y 0–255 0=full up, 128=centre, 255=full down [3] Right stick X 0–255 same axis mapping [4] Right stick Y 0–255 same axis mapping ── BUTTONS — BYTE 5 ───────────────────────────────────────────────────────── [5] bit 7 Triangle 1 = pressed bit 6 Circle 1 = pressed bit 5 Cross (✕) 1 = pressed bit 4 Square 1 = pressed bits 3–0 D-pad 0=↑ 1=↗ 2=→ 3=↘ 4=↓ 5=↙ 6=← 7=↖ 8=none ── BUTTONS — BYTE 6 ───────────────────────────────────────────────────────── [6] bit 7 R3 right stick click bit 6 L3 left stick click bit 5 Options ☰ bit 4 Share ⬡ bit 3 R2 digital 1 = any trigger pressure bit 2 L2 digital 1 = any trigger pressure bit 1 R1 right bumper bit 0 L1 left bumper ── BUTTONS — BYTE 7 ───────────────────────────────────────────────────────── [7] bit 1 Touchpad click tap the pad surface bit 0 PS button the glowing PlayStation logo ── ANALOG TRIGGERS ────────────────────────────────────────────────────────── [8] L2 analog 0–255 0=not pressed, 255=fully depressed [9] R2 analog 0–255 0=not pressed, 255=fully depressed ── IMU — GYROSCOPE (16-bit signed, little-endian) ─────────────────────────── [13–14] Gyro pitch ±32767 nose up/down (deg/s × scale) [15–16] Gyro yaw ±32767 rotate left/right [17–18] Gyro roll ±32767 tilt left/right ── IMU — ACCELEROMETER (16-bit signed, little-endian) ─────────────────────── [19–20] Accel X ±32767 side to side [21–22] Accel Y ±32767 top to bottom [23–24] Accel Z ±32767 forward/back ── TOUCHPAD (first touch point) ───────────────────────────────────────────── [35] Touch active bit 7 = 0 0 = finger on pad, 1 = no touch [36–37] Touch X 0–1919 1920px virtual width [38] Touch Y bits 0–941 942px virtual height (packed) ── STATUS ──────────────────────────────────────────────────────────────────── [30] Battery level 0–15 15 = full charge bit 4 Charging 1 = cable connected ───────────────────────────────────────────────────────────────────────────── Total: 64 bytes · 60 Hz · every input on the controller · no magic
Raw bytes are not an API. We parse the 64-byte report exactly once, at the boundary where the UDP packet arrives, and produce a ControllerState struct with named fields. Everything downstream — the motor task, the LED task, the mode-switching logic — works with meaningful Rust types, not byte offsets. The borrow checker enforces that no task reads a partially-updated state.
/// Every input on a DualShock 4, named and typed. #[derive(Debug, Clone, Default)] pub struct ControllerState { // ── Analog sticks ───────────────────────────────────────── pub left_x: f32, // -1.0 (left) to +1.0 (right) pub left_y: f32, // -1.0 (up) to +1.0 (down) pub right_x: f32, // -1.0 (left) to +1.0 (right) pub right_y: f32, // -1.0 (up) to +1.0 (down) // ── Analog triggers ─────────────────────────────────────── pub l2: f32, // 0.0 (released) to 1.0 (floored) pub r2: f32, // 0.0 (released) to 1.0 (floored) // ── Face buttons ────────────────────────────────────────── pub triangle: bool, pub circle: bool, pub cross: bool, pub square: bool, // ── Shoulder buttons ────────────────────────────────────── pub l1: bool, pub r1: bool, pub l3: bool, // left stick click pub r3: bool, // right stick click // ── D-pad ───────────────────────────────────────────────── pub dpad: DPad, // ── System buttons ──────────────────────────────────────── pub share: bool, pub options: bool, pub ps: bool, pub touchpad_click: bool, // ── Touchpad ────────────────────────────────────────────── pub touch_active: bool, pub touch_x: u16, // 0–1919 pub touch_y: u16, // 0–941 // ── IMU ─────────────────────────────────────────────────── pub gyro_pitch: i16, pub gyro_yaw: i16, pub gyro_roll: i16, pub accel_x: i16, pub accel_y: i16, pub accel_z: i16, // ── Status ──────────────────────────────────────────────── pub battery: u8, // 0–15 pub charging: bool, } #[derive(Debug, Clone, PartialEq, Default)] pub enum DPad { #[default] Released, Up, UpRight, Right, DownRight, Down, DownLeft, Left, UpLeft, } /// Dead zone: ignore stick drift within ±threshold fn axis(raw: u8, dead: f32) -> f32 { let v = (raw as f32 - 128.0) / 128.0; // map 0–255 → -1.0..+1.0 if v.abs() < dead { 0.0 } else { v } } impl ControllerState { /// Parse a 64-byte USB HID report into a ControllerState. /// Returns None if the report ID is wrong or the slice is too short. pub fn from_report(r: &[u8]) -> Option<Self> { if r.len() < 40 || r[0] != 0x01 { return None; } const DEAD: f32 = 0.08; // 8% dead zone — tune to your controller let btn5 = r[5]; let btn6 = r[6]; let btn7 = r[7]; let dpad = match btn5 & 0x0F { 0 => DPad::Up, 1 => DPad::UpRight, 2 => DPad::Right, 3 => DPad::DownRight, 4 => DPad::Down, 5 => DPad::DownLeft, 6 => DPad::Left, 7 => DPad::UpLeft, _ => DPad::Released, }; // Touchpad — bytes 35–38 (first touch point) let touch_active = r[35] & 0x80 == 0; let touch_x = (r[36] as u16) | (((r[37] & 0x0F) as u16) << 8); let touch_y = ((r[37] >> 4) as u16) | ((r[38] as u16) << 4); Some(ControllerState { left_x: axis(r[1], DEAD), left_y: axis(r[2], DEAD), right_x: axis(r[3], DEAD), right_y: axis(r[4], DEAD), l2: r[8] as f32 / 255.0, r2: r[9] as f32 / 255.0, triangle: btn5 & 0x80 != 0, circle: btn5 & 0x40 != 0, cross: btn5 & 0x20 != 0, square: btn5 & 0x10 != 0, l1: btn6 & 0x01 != 0, r1: btn6 & 0x02 != 0, l3: btn6 & 0x40 != 0, r3: btn6 & 0x80 != 0, dpad, share: btn6 & 0x10 != 0, options: btn6 & 0x20 != 0, ps: btn7 & 0x01 != 0, touchpad_click: btn7 & 0x02 != 0, touch_active, touch_x, touch_y, gyro_pitch: i16::from_le_bytes([r[13], r[14]]), gyro_yaw: i16::from_le_bytes([r[15], r[16]]), gyro_roll: i16::from_le_bytes([r[17], r[18]]), accel_x: i16::from_le_bytes([r[19], r[20]]), accel_y: i16::from_le_bytes([r[21], r[22]]), accel_z: i16::from_le_bytes([r[23], r[24]]), battery: r[30] & 0x0F, charging: r[30] & 0x10 != 0, }) } }
Pair the DS4 to your laptop once — on Windows, Settings → Bluetooth, hold the PS + Share buttons until the light bar flashes. Then run this script. It finds the controller, reads HID reports at 60Hz, and forwards each 64-byte report as a UDP packet to whatever IP your Pico 2W gets from the router.
#!/usr/bin/env python3 # pip install hid # Run: python bridge.py --host 192.168.x.x --port 4242 import hid, socket, argparse, time DS4_VENDOR = 0x054C DS4_PRODUCT = 0x05C4 # original DS4 DS4_PRODUCT2= 0x09CC # DS4 v2 (CUH-ZCT2) def main(): p = argparse.ArgumentParser() p.add_argument('--host', required=True) p.add_argument('--port', type=int, default=4242) args = p.parse_args() sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) dest = (args.host, args.port) # Find controller gamepad = None for pid in [DS4_PRODUCT, DS4_PRODUCT2]: try: gamepad = hid.device() gamepad.open(DS4_VENDOR, pid) break except: gamepad = None if not gamepad: print("DS4 not found — pair it over Bluetooth first") return gamepad.set_nonblocking(False) print(f"DS4 connected → forwarding to {args.host}:{args.port}") while True: report = gamepad.read(64) if report: sock.sendto(bytes(report), dest) if __name__ == '__main__': main()
The Pico 2W runs three Embassy tasks. The net_task drives the CYW43439 WiFi stack. The udp_task joins WiFi, binds port 4242, receives reports, parses them into ControllerState, and pushes to a signal. The motor_task reads the signal and drives the L298N — the same L298N wiring from Chapter 10, the same GPIO pins.
[package] name = "ds4-motor" version = "0.1.0" edition = "2021" [dependencies] embassy-executor = { version = "0.6", features = ["arch-cortex-m", "executor-thread"] } embassy-rp = { version = "0.2", features = ["rp2350", "time-driver", "unstable-pac"] } embassy-net = { version = "0.4", features = ["udp", "dhcpv4", "medium-ethernet"] } embassy-time = "0.3" cyw43 = { version = "0.2", features = ["firmware-pico-w"] } cyw43-pio = "0.2" static-cell = "2" defmt = "0.3" defmt-rtt = "0.4" panic-probe = { version = "0.3", features = ["print-defmt"] } fixed = "1" heapless = "0.8" [profile.release] opt-level = "s" lto = "thin" [[bin]] name = "ds4-motor" test = false
#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_rp::{ bind_interrupts, gpio::{Level, Output}, peripherals::{DMA_CH0, PIN_23, PIN_24, PIN_25, PIN_29, PIO0, PWM_SLICE5}, pio::InterruptHandler as PioInterruptHandler, pwm::{Config as PwmConfig, Pwm}, }; use embassy_net::{ udp::{PacketMetadata, UdpSocket}, IpListenEndpoint, Stack, StackResources, }; use embassy_time::Timer; use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, signal::Signal}; use cyw43_pio::PioSpi; use static_cell::StaticCell; use defmt::*; use {defmt_rtt as _, panic_probe as _}; mod controller; use controller::{ControllerState, DPad}; // ── WiFi credentials ─────────────────────────────────────────── const WIFI_SSID: &str = "YourNetworkName"; const WIFI_PASS: &str = "YourPassword"; const UDP_PORT: u16 = 4242; // ── L298N wiring (same as Chapter 10) ───────────────────────── // GPIO8 = IN1, GPIO9 = IN2 (direction), GPIO10 = ENA (PWM speed) // ── Shared state: UDP task → Motor task ─────────────────────── static CTRL_SIGNAL: Signal<ThreadModeRawMutex, ControllerState> = Signal::new(); bind_interrupts!(struct Irqs { PIO0_IRQ_0 => PioInterruptHandler<PIO0>; }); #[embassy_executor::main] async fn main(spawner: Spawner) { let p = embassy_rp::init(Default::default()); info!("DS4 Motor Controller starting"); // ── CYW43 WiFi init ──────────────────────────────────────── let fw = include_bytes!("../firmware/43439A0.bin"); let clm = include_bytes!("../firmware/43439A0_clm.bin"); let pwr = Output::new(p.PIN_23, Level::Low); let cs = Output::new(p.PIN_25, Level::High); let spi = PioSpi::new(&mut p.PIO0, p.PIN_24, p.PIN_29, p.DMA_CH0, cs); static STATE: StaticCell<cyw43::State> = StaticCell::new(); let state = STATE.init(cyw43::State::new()); let (net_device, mut control, runner) = cyw43::new(state, pwr, spi, fw).await; spawner.spawn(cyw43_task(runner)).unwrap(); control.init(clm).await; control.set_power_management(cyw43::PowerManagementMode::PowerSave).await; // ── Network stack ────────────────────────────────────────── static RESOURCES: StaticCell<StackResources<5>> = StaticCell::new(); let (stack, runner) = embassy_net::new( net_device, embassy_net::Config::dhcpv4(Default::default()), RESOURCES.init(StackResources::new()), embassy_rp::clocks::RoscRng::new().next_u64(), ); spawner.spawn(net_task(runner)).unwrap(); // ── Join WiFi ────────────────────────────────────────────── loop { match control.join_wpa2(WIFI_SSID, WIFI_PASS).await { Ok(_) => break, Err(e) => { warn!("WiFi join failed: {}", e.status()); Timer::after_secs(2).await; } } } info!("WiFi joined — waiting for DHCP..."); stack.wait_config_up().await; let ip = stack.config_v4().unwrap().address; info!("Pico IP: {} — run bridge.py --host {}", ip, ip); // ── Spawn tasks ──────────────────────────────────────────── spawner.spawn(udp_task(stack)).unwrap(); spawner.spawn(motor_task(p.PWM_SLICE5)).unwrap(); } /// Receives 64-byte UDP reports → parses → signals motor task #[embassy_executor::task] async fn udp_task(stack: &'static Stack<cyw43::NetDriver<'static>>) { let mut rx_meta = [PacketMetadata::EMPTY; 4]; let mut rx_buf = [0u8; 512]; let mut tx_meta = [PacketMetadata::EMPTY; 4]; let mut tx_buf = [0u8; 128]; let mut payload = [0u8; 64]; let mut socket = UdpSocket::new( stack, &mut rx_meta, &mut rx_buf, &mut tx_meta, &mut tx_buf, ); socket.bind(UDP_PORT).unwrap(); info!("UDP listening on port {}", UDP_PORT); loop { let (n, _from) = socket.recv_from(&mut payload).await.unwrap(); if n >= 40 { if let Some(state) = ControllerState::from_report(&payload[..64]) { CTRL_SIGNAL.signal(state); } } } } /// Reads controller state → drives L298N H-bridge #[embassy_executor::task] async fn motor_task(pwm_slice: PWM_SLICE5) { use embassy_rp::gpio::{Output, Level}; use embassy_rp::peripherals::{PIN_8, PIN_9}; // Direction pins let mut in1 = Output::new(unsafe { PIN_8::steal() }, Level::Low); let mut in2 = Output::new(unsafe { PIN_9::steal() }, Level::Low); // PWM on GPIO10 (ENA) — slice 5, 20kHz let mut cfg = PwmConfig::default(); cfg.top = 7499; cfg.divider = fixed::types::U16F16::from_num(1); let mut pwm = Pwm::new_output_a(pwm_slice, unsafe { embassy_rp::peripherals::PIN_10::steal() }, cfg.clone()); let mut mode = DriveMode::Tank; loop { let ctrl = CTRL_SIGNAL.wait().await; // ── PS button = emergency stop ────────────────────────── if ctrl.ps { stop(&mut in1, &mut in2, &mut pwm, &mut cfg); info!("E-STOP: PS button"); continue; } // ── Options = switch drive mode ───────────────────────── if ctrl.options { mode = match mode { DriveMode::Tank => DriveMode::Arcade, DriveMode::Arcade=> DriveMode::Tank, }; info!("Mode: {:?}", mode); } // ── R2 = turbo (bypass speed limit) ──────────────────── let speed_cap = if ctrl.r1 { 1.0_f32 } else { 0.6_f32 }; // ── L1 = precision mode (10% of stick travel) ────────── let scale = if ctrl.l1 { 0.1_f32 } else { 1.0_f32 }; let throttle = match mode { DriveMode::Tank => -ctrl.left_y * scale, DriveMode::Arcade=> { // R2 trigger = forward, L2 trigger = reverse (ctrl.r2 - ctrl.l2) * scale } }; let speed = (throttle.abs() * speed_cap).clamp(0.0, 1.0); let duty = (speed * 7499.0) as u16; if throttle > 0.0 { in1.set_high(); in2.set_low(); // forward } else if throttle < 0.0 { in1.set_low(); in2.set_high(); // reverse } else { in1.set_low(); in2.set_low(); // coast } cfg.compare_a = duty; pwm.set_config(&cfg); } } fn stop( in1: &mut Output, in2: &mut Output, pwm: &mut Pwm, cfg: &mut PwmConfig ) { in1.set_low(); in2.set_low(); cfg.compare_a = 0; pwm.set_config(cfg); } #[derive(Debug, Clone)] enum DriveMode { Tank, Arcade } #[embassy_executor::task] async fn cyw43_task(runner: cyw43::Runner<'static, Output<'static>, PioSpi<'static, PIO0, 0, DMA_CH0>>) -> ! { runner.run().await } #[embassy_executor::task] async fn net_task(mut runner: embassy_net::Runner<'static, cyw43::NetDriver<'static>>) -> ! { runner.run().await }
You now have a ControllerState arriving 60 times per second. Every field is yours. Here is the full mapping philosophy — what each input is suited for and a concrete suggestion for your toy:
BUTTON MAPPING REFERENCE — DS4 ON PICO 2W ─────────────────────────────────────────────────────────────────────── INPUT TYPE SUGGESTED USE ─────────────────────────────────────────────────────────────────── Left stick Y analog Motor throttle (forward/reverse) Tank mode: direct speed from -1.0→+1.0 Left stick X analog Steering (if you add a second motor or servo). Differential: add to left, subtract from right motor speed. Right stick X analog Camera pan / servo sweep Right stick Y analog Camera tilt / arm elevation L2 trigger analog Reverse speed (0.0–1.0) R2 trigger analog Forward speed (0.0–1.0) Arcade mode: R2-L2 = net throttle L1 digital Precision mode (10% speed cap) R1 digital Turbo mode (remove speed cap) Cross (✕) digital Horn / buzzer pulse Circle digital Toggle headlight LED Square digital Brake (active braking: IN1+IN2 high) Triangle digital Switch camera mode / aux function D-pad Up/Down 8-way Trim motor bias (tune straight-line) D-pad Left/Right 8-way Rotate in place (zero-radius turn) Options digital Toggle drive mode (Tank ↔ Arcade) Share digital Log current state via defmt/RTT L3 (stick click) digital Reset trim to zero R3 (stick click) digital Toggle RTT debug output verbosity PS button digital EMERGENCY STOP — always checked first Touchpad click digital Secondary E-STOP (alternative reach) Touchpad X 0–1919 Throttle curve selection (swipe left/right) Touchpad Y 0–941 PID Kp adjustment (swipe up = more gain) Gyro pitch/roll i16 ±32767 Tilt-to-steer mode (hold flat = straight, tilt controller forward = drive forward) Accel X/Y/Z i16 ±32767 Gesture detection (shake = horn, flip upside-down = lights off) ─────────────────────────────────────────────────────────────────── All inputs live in ControllerState. Parse once. Use everywhere.
When you add a second motor — and with a harvested motor on the shelf it is only a matter of time — the left stick controls both simultaneously using differential drive mathematics. Push the stick straight forward: both motors run at full speed. Push it forward and left: the left motor slows, the right motor speeds up, and the chassis pivots left. This is how tracked vehicles turn, how tank steering works, how every serious remote-controlled platform moves.
/// Given left stick X and Y, compute left and right motor speeds. /// Returns (left_speed, right_speed) each in range -1.0..+1.0 /// Negative = reverse, positive = forward. fn differential_drive(lx: f32, ly: f32) -> (f32, f32) { // ly is inverted: stick up = -1.0, we want that to mean forward let throttle = -ly; let turn = lx; // Mix: add turn to right side, subtract from left side let left = (throttle + turn).clamp(-1.0, 1.0); let right = (throttle - turn).clamp(-1.0, 1.0); (left, right) } /// Convert -1.0..+1.0 to direction + PWM duty (0–7499) fn apply_motor( speed: f32, in_a: &mut Output, in_b: &mut Output, duty: &mut u16, ) { if speed > 0.01 { in_a.set_high(); in_b.set_low(); *duty = (speed * 7499.0) as u16; } else if speed < -0.01 { in_a.set_low(); in_b.set_high(); *duty = (speed.abs() * 7499.0) as u16; } else { in_a.set_low(); in_b.set_low(); // coast *duty = 0; } } // In motor_task loop: let (left, right) = differential_drive(ctrl.left_x, ctrl.left_y); apply_motor(left * speed_cap, &mut in1_l, &mut in2_l, &mut left_duty); apply_motor(right * speed_cap, &mut in1_r, &mut in2_r, &mut right_duty);
The DS4 contains a full IMU — gyroscope and accelerometer. The gyroscope measures angular velocity; the accelerometer measures gravity direction. Together they let you steer by tilting the controller. Hold the controller flat and the car goes straight. Tilt it forward and it accelerates. Tilt it left and it turns left. This is not a gimmick — it is a different control paradigm that some applications (cranes, remote arms, precision positioning) genuinely suit better than sticks.
/// Map accelerometer gravity vector to throttle and steering. /// Hold controller face-up, flat = neutral. /// Tilt towards you = forward, away = reverse, left/right = turn. fn tilt_control(ctrl: &ControllerState) -> (f32, f32) { // Normalise to -1.0..+1.0. Raw range ±32767, gravity ≈ ±8192 at 1g. let tilt_forward = -(ctrl.accel_y as f32 / 8192.0).clamp(-1.0, 1.0); let tilt_side = (ctrl.accel_x as f32 / 8192.0).clamp(-1.0, 1.0); // Dead zone: ignore tiny tilts from hand tremor const TILT_DEAD: f32 = 0.15; let throttle = if tilt_forward.abs() > TILT_DEAD { tilt_forward } else { 0.0 }; let steer = if tilt_side.abs() > TILT_DEAD { tilt_side } else { 0.0 }; differential_drive(steer, -throttle) } // Toggle tilt mode with Triangle: let (left, right) = if tilt_mode { tilt_control(&ctrl) } else { differential_drive(ctrl.left_x, ctrl.left_y) };
Cross Button → Buzzer
Wire a passive buzzer to GPIO15. When the Cross button is held, drive a 440Hz square wave using Embassy's PWM (slice 7, channel B). When released, stop the PWM. Implement it as a separate buzzer_task listening to the same CTRL_SIGNAL. Bonus: hold Square for a different frequency (880Hz), hold both for a chord.
Accelerometer → Gesture
Read accel_x, accel_y, accel_z from the controller state. Compute the total acceleration magnitude as sqrt(x² + y² + z²). If the magnitude exceeds 2g (approximately 16384 in raw units) for three consecutive frames, trigger a "shake" event — flash an LED on GPIO16 three times in quick succession. This is the first gesture recogniser in your embedded career.
Add a Second Motor — Full Differential Drive
Wire a second DC motor and second L298N channel (IN3=GPIO11, IN4=GPIO12, ENB=GPIO13, PWM slice 6). Implement the full differential drive system: left stick Y = speed, left stick X = turn, L1 = precision, R1 = turbo, PS = E-STOP, Options = mode toggle (Tank/Arcade/Tilt). This is the toy that was bought but never built. Finish it.