Cardminton—playing badminton with a Cardputer as the racket A developer created Cardminton, a badminton game for the M5Stack Cardputer that uses its built-in gyroscope and accelerometer to detect swings, smashes, and drops. The game runs entirely on the device at 200Hz, with a browser rendering the 3D scene via a 30Hz snapshot stream. The project avoids machine learning in favor of a threshold state machine for swing detection, achieving low latency and accurate gameplay. I love badminton, but it's too hot in the summer and I'm always too lazy to go out. So I thought, why not make my own badminton game like the ones in Nintendo games? Luckily, my Cardputer has a built-in gyroscope and accelerometer, so with the help of AI, I created this project. PlayingSince my English isn't native, the following content was edited into English by AI. Power it on and the little screen alternates between two QR codes. The first joins your phone to the hotspot quietly — the phone keeps its cellular internet , the second opens the game at 192.168.4.1 in the browser. From the pocket to a rally takes about half a minute. Then you swing. Up gives a clear, a downward chop is a smash, a soft touch drops the shuttle just over the net, and the direction of your sweep aims the shot left or right. A ring closes around the landing point of the incoming shuttle; swinging as it shuts is the sweet spot. Once a screen connects, the device's own display switches from the pairing QR codes to a little scoreboard — score, serve marker, match state — which sells the "this little thing is the console" feeling more than I expected. An earlier version of this project did the obvious thing: stream raw IMU data over BLE and let a browser compute everything. It worked, but the Cardputer was reduced to a sensor dongle, and the latency budget was spent in the wrong place — every swing had to cross BLE batching before the game could even decide whether it was a hit. Moving the game onto the device fixed that properly. Detection now runs at 200Hz on the same chip that samples the IMU, so the judgment path involves no radio at all. The browser's job shrinks to rendering: The interesting question is how a 30Hz snapshot stream turns into a smooth 60fps picture. The answer is the standard multiplayer-game trick applied to embedded: each snapshot carries the shuttle's position, velocity and a device timestamp, and the browser integrates the same physics forward to "now" before drawing. Both sides run the same deterministic integrator from the same state, so the next snapshot lands within a couple of pixels of the prediction and corrections are invisible. Hits change the trajectory, which is why the tick that launches a shot broadcasts immediately instead of waiting for the grid. Timing needed one more piece. The closing ring tells you when to swing, so it has to agree with the clock the device judges you by. The page estimates the device clock over WebSocket ping/pong, keeping the lowest-RTT samples, and renders the ring against device time. On a LAN with 2–10ms round trips that's accurate to well under a frame. The swing engineI tried per-user gesture training first: record samples, train a small classifier. It was unplayable — over half a second of latency and constant misfires — and after digging into how Wii Sports actually works I concluded this problem doesn't want machine learning at all. A threshold state machine that commits on the rising edge of the swing does it better, with no training and no calibration: IDLE → ARMED gyro 150°/s — latch the gravity vector → FIRE gyro 240°/s AND accel excursion 0.25 g → HOLD 250 ms during which measured power may only improve → REFRACTORY 500 ms — covers the follow-through bounce A few details matter. Gravity is only updated while the device is still, and frozen during the swing, because accelerometer-based attitude correction is actively wrong while you're accelerating the device. Vertical intent comes from strapdown integration: propagate attitude from the moment of arming using the gyro alone, rotate the accelerometer samples back into that frame, and integrate vertical hand velocity. That quantity is literally "is the hand moving up or down", regardless of grip. The acceleration gate is what rejects false positives — turning the device over in your hand produces plenty of gyro signal but only about 0.15g of centripetal acceleration, while even a lazy real swing translates the device at 0.34g or more. The firmware engine is a C++ port of a JavaScript original, and the test suite holds them to identical output across 81 recorded real swings — fire time, power and direction with zero deviation — plus synthetic cases sensor noise, slow tumbling, walking that must never fire. Shuttlecock physicsA shuttlecock has a terminal velocity around 6.8 m/s and v² drag, which is why a smash dies before the back line and a clear falls almost vertically. The whole flight model fits in one function: js Vec3 shuttleAccel const Vec3& v {const float sp = vlen v ;const float drag = g sp / vt sp / vt ;return { -drag v.x/sp, -drag v.y/sp, -g - drag v.z/sp };} Shots are built by bisection on top of it. Smashes solve for elevation at a fixed speed, arcing shots solve for speed at a fixed elevation, and net-skimming drops search for the lowest elevation whose trajectory still clears the tape with a margin. The CPU's unforced errors come only from a per-difficulty dice roll: every CPU shot is verified to clear the net before launch, with an escalation to a steep rescue lift for shuttles dug from right under the tape. The "Unbeatable" tier really never beats itself. Things that went wrongThe captive portal kept killing its own WiFi. The first network design ran a wildcard DNS and redirected everything, so phones popped the OS "captive WiFi" sheet with the game inside it. That demos great and plays terribly: the sheet is a crippled mini-browser, and on iOS dismissing it often drops the WiFi along with it. The fix was to delete the DNS server entirely. With no DNS, the OS connectivity probes just fail, the phone files the hotspot under "local network, no internet", joins silently and keeps cellular for everything else. The game page is reached by raw IP from the second QR code. Every hit caused a 100ms hitch. The net-clearance solver runs on the order of a hundred trajectory simulations per shot, and it was doing so inside the 60Hz game tick at the exact moment of contact — precisely where your eye is pointed. Three changes fixed it: compiling the solver with -O2 instead of -Os, trimming the bisection depths the precision left over is still far below the aim noise every shot carries anyway , and pre-solving the CPU's return at its "reaction" moment instead of at contact. That last one is the satisfying part: mid-flight the trajectory isn't changing, so the browser's dead reckoning glides straight through the solver stall and nobody sees a thing. M5Unified quietly halves your smashes. The BMI270 powers on at ±8g and the library never touches the range registers, so real smashes clip. The firmware sets 200Hz/±16g itself and compensates for the driver's hardcoded 8g conversion scale. Building it git clone https://github.com/lxhyl/cardmintoncd cardminton/firmware/cardminton-hostpio run -t upload That's the entire deployment: the web frontend about 250KB gzipped is embedded into the firmware image by a PlatformIO pre-script, so there is no filesystem image or second upload step. The test suite runs without any hardware: cd native && make run C++ core: swing parity + physics + a full matchnpm test JS reference: replay, game logic, strapdown Two playersThe host exposes an open racket endpoint: anything that streams the documented binary IMU format to ws://192.168.4.1/ p2 becomes player two, and the screen splits. The connection is host-controlled — the device parks the newcomer as a candidate and player one clicks to accept it. firmware/cardminton-racket/ turns a second Cardputer into that racket in about 200 lines: join the AP, stream the IMU, show the candidate/GO status on screen. One honest caveat: I own exactly one Cardputer, so this firmware compiles and follows the protocol but has never met the host on real hardware. If you have two devices, I'd love to hear how it goes. What's in the repoEverything is MIT licensed: the firmware, the browser renderer, the native test harness, the recorded swing fixtures, and the design docs with the full reasoning — including the approaches that didn't survive. Read more javascript:void 0