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 i created this project.
Since my English isn't native, the following content was edited into English by AI.Playing
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:
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 itgit clone https://github.com/lxhyl/cardminton
cd cardminton/firmware/cardminton-host
pio run -t uploadThat'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 match
npm test # JS reference: replay, game logic, strapdownTwo 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.











Comments