Retro games meet sonar, right on your phone. You play by moving your palm in mid-air: the phone hears it by ultrasound. No touch, no camera.
See the codeEnglish · Русский

Games you play with your palm in mid-air. You never touch the screen: the phone plays an inaudible ultrasonic tone through its own speaker, listens to the echo with its own microphone and works out how far your palm is. That distance becomes the height of your ship or your car. No camera, no extra hardware, nothing to install — it is a web page.
▶ Play: sonaroids.app

The ship fires on its own; all you choose is where it is.


![]()
The car drives itself and slowly speeds up; your palm steers it across the road.
There are two ways:
| on the table | in your hand |
|---|---|
| The phone lies sideways, screen up; your palm moves up and down 5–10 cm above the table beside it | The phone is in one hand; the other palm, edge down, moves 5–15 cm from the end with the charging port |
A game lasts a few minutes: the pace keeps growing, and a hand held in the air gets tired too — that's part of it.
Skins in both games: the menu's «◀ ▶» arrows step through the skins, a tap in the middle opens the «Skins» screen. There you leaf through the skins and build a playlist with «+ ADD TO PLAYLIST» / «- REMOVE»; it is shown as a line under the card. One skin in it — you always play in it; several — they change during the game, in turn or at random, after the time you set (15–120 s, or 25–40 s at random). The «All in a row» tick turns them all on; untick it and your playlist comes back.
The pause in both games: go on, start over (straight away or with a new fit), end the game, exit to the menu. Beside them are the skin, the graphics (HD or pixels) and the sounds "- SOUNDS ▮▮▮▮▮▮▯▯ +": eight levels, a tap in the middle switches them off and on. The skin and the graphics can be changed in the middle of a game. If the game's own sounds get too loud for the microphone, it turns them down by itself.
Settings (the games' screen): sounds; graphics (chosen in the game, always HD or always pixels); the probe band (chosen before a game, always wide or always normal); auto-calibration; room following (for the manual calibration); advanced probe settings (only in the Android app — the microphone, the speaker, the volume).
Again after a game — straight to the countdown with the same calibration; "Recalibrate" beside it. If a palm was over the phone while the empty room was measured, the room is measured once more.
Room following (off by default): the manual calibration, but the room is learnt during the game too, as in auto-calibration.
Auto-calibration (off by default): play one game the usual way (with the waving), then switch it on. From then on the game gets ready in a couple of seconds with no waving — it takes the range and the middle of your last manual calibration for this probe band (the middle 6 mm farther from the phone, so the bottom of the screen doesn't end up at the port; the calibration's date is in the settings), shows a try-out flight ("Play" or "Recalibrate"), "Again" goes straight to the countdown, and the echo processing keeps learning the room during the game — a noisier room or a thing put down near the phone doesn't take the palm away. The screen itself is not refitted to the palm in flight: every try at that (1.10–1.13) steered worse. If it steers worse, switch it off, play with the manual calibration, switch it on again.
If something's wrong: a tap on the version on the games' screen reloads the page, and a long press opens "Logs". They record what the phone heard and can be attached to an issue.
iPhone plays well in Safari.
On Android a lot depends on the phone itself: how well its speaker and microphone pass 16–20 kHz. In a browser the page can't choose which microphone records, and doesn't know how loud the phone plays. That is what the app (android/) is for. Inside it shows the same site, so the games in it are always current, but it records and plays the sound itself:
With the app and the wide probe a Mi 9 Lite (from 2019) plays like an iPhone. The notes games send to the server show the palm heard on Galaxy S25, Pixel 8 Pro, Nothing Phone (3a), realme GT Neo 5 and Honor 600 Lite. A OnePlus 13 is poor on the table but almost as good as an iPhone in the hand. A Redmi Note 10S hears the palm reliably only within about 10 cm. A OnePlus 9 Pro is too quiet. The game works out which end of the phone your hand should be at, and says so if it can't hear the palm. Details: docs/design.md, section 2.
Each game has its own tables: today, this week and all time. The first time a game makes a table, it asks for a name (Latin letters, digits, _). A transfer code moves your name and scores to another browser or phone.
Bots play in the tables too — about 40 players the server plays itself, from beginners to strong ones, so there is always someone to beat. Their games are real — the game's bot, by the same rules — and the strongest stay a little below the best person. The «PEOPLE ONLY» tab over the table leaves them out.
What goes to the server is not a score but the game itself: the layout number and the palm height at every step, a few KB. Both games are deterministic — even steps 60 times a second, random numbers from a seed, no transcendental functions. So the server replays every game with the very same code (src/13_core.js for SonaFly, src/14_race.js for SonaRace) and keeps the score only if it repeats.
A player is a random key kept on the phone; the server stores only its hash. With each game goes a short note on the phone: iOS or Android, the browser, the model (on Android), which probe, the volume, how well it heard the probe. That shows where the sonar works. The server stores no user agent string and no IP. It is plain Node + SQLite, in server/.
lab/HANDOVER.md (Russian).| path | what's inside |
|---|---|
src/ | the games' source in numbered parts; build.py joins them into one file, game/play/index.html |
game/ | the site: index.html sends visitors into the games, play/ is the games (built file, icons, manifest, service worker), font.js, robots.txt |
server/ | the high-score server (api.sonaroids.app): server.js, the bots (bots.js, botplay.js), backups, stats, systemd unit, Caddy and nginx configs. Install: server/README.md, in Russian docs/ru/server.md |
android/ | the Android app: a WebView over sonaroids.app/play/ with its own sound, volume, file saving and self-update. Built on GitHub Actions into the latest release (android/README.md, in Russian) |
tests/ | the games' and the server's checks: sh tests/run.sh |
lab/ | the sonar lab: the test app lab/app/sonar_lab3.html, its sources, benches and recordings; notes in Russian |
hands/ | make_hands.py draws the HD hands of the getting-ready pictures into src/42_hands.js (with the promo GIF's hand, promo/promo_hand.py) |
font/ | the 5×7 pixel font (Latin and Cyrillic): font5x7.txt is the drawing, make_font.py packs it into game/font.js |
promo/ | the GIFs and pictures in this README, taken from the real games (see below) |
docs/ | the design document design.md (Russian original in docs/ru/) and the server install guide in Russian |
src/)| part | what it does |
|---|---|
00_head.html, 99_end.html | the page around the script |
10_worklet.js, 11_dsp.js | microphone frames and the echo processing — the same code as in the lab |
12_tune.js | fitting the screen to the palm's range |
13_core.js | SonaFly: the flight and its rules; deterministic, with the rules tag the server checks |
14_race.js | SonaRace: the road, the cars, the gifts and the rules; deterministic, with its own rules tag |
20_sonar.js | speaker and microphone: the probe, side check, levels and volume, getting ready; the Android app's own sound |
21_log.js | setup and game logs (WAV + JSON) for the lab's tools |
22_sfx.js | the game's sounds — all below 6 kHz, out of the probe's way |
23_net.js | the high-score client: sends a game and the name, reads the tables |
30_lang_en.js, 31_lang_ru.js | interface texts |
40_gfx.js, 41_sprites.js, 42_guide.js | drawing, pixel sprites, the drawn phones of getting ready and the instructions |
42_hands.js | the hands of those pictures in HD, as pictures — made by hands/make_hands.py |
43_skins.js, 45_hd.js, 46_vecskins.js, 47_pixskins.js, 48_sizes.js | SonaFly's skins: pixels, HD, shape skins; things drawn the size the game counts them |
48_racehd.js, 48_racecandy.js, 48_racenote.js, 48_racepirate.js | SonaRace's candy land, notebook and pirate world, in HD and in pixels |
48_mix.js | the skins' list: which are on, how often and in what order they change in a game |
49_main.js | the screens, the menus and the game loop |
python3 build.py # src/ → game/play/index.html (python3 font/make_font.py after changing the font)
python3 build.py --icons # redraw the Home Screen icons (needs Pillow)
sh tests/run.sh # the games and the server (~9 min: the real-time ones alone, then the rest two at once)
cd lab && sh tools/run_all.sh # the sonar lab
Node 22.13+ (the server uses node:sqlite) and Python 3. The screen and full-game checks play both games in headless Chromium with a synthetic microphone and a fake high-score server. They need Playwright and are skipped without it. On every check bots drive over a hundred SonaRace races to keep a race's length and difficulty where they were tuned. GitHub Actions runs the checks on every push to main and publishes the site only if they pass.
The version is in VERSION: it shows on the games' screen and goes into the logs. CHANGELOG.md says what each version changed and why. When a change alters play, the rules tag in src/13_core.js or src/14_race.js changes too, and the server is updated together with the site.
All taken from the real games in headless Chromium (needs Playwright):
PROMO=mix NODE_PATH=$(npm root -g) node promo/make_promo.js && PROMO=mix python3 promo/make_promo.py # the GIF at the top (the skin playlist): promo/sonaroids_skins_en.gif, _ru (+ *_full.gif, 800×450)
NODE_PATH=$(npm root -g) node promo/make_promo.js && python3 promo/make_promo.py # the earlier GIF, four games one after another: promo/sonaroids_en.gif, sonaroids_ru.gif
PROMO=pixel NODE_PATH=$(npm root -g) node promo/make_promo.js && PROMO=pixel python3 promo/make_promo.py # the same with the games in pixels: promo/sonaroids_pixel_*.gif
NODE_PATH=$(npm root -g) node promo/make_shots.js # the games' pictures: promo/games.png, sonafly_skins.png, sonarace_*.png
sh promo/make_all.sh # the older pixel GIFs (table and hand)
The GIFs also need numpy, Pillow and ffmpeg. The palm in them is drawn by code (promo/promo_hand.py); the games on the screen really play, steered by that same palm.
MIT — see LICENSE.
HTML
76.1%
JavaScript
16.7%
Python
3.9%
Java
2.6%
Retro games meet sonar, right on your phone. You play by moving your palm in mid-air: the phone hears it by ultrasound. No touch, no camera.
See the codeEnglish · Русский

Games you play with your palm in mid-air. You never touch the screen: the phone plays an inaudible ultrasonic tone through its own speaker, listens to the echo with its own microphone and works out how far your palm is. That distance becomes the height of your ship or your car. No camera, no extra hardware, nothing to install — it is a web page.
▶ Play: sonaroids.app

The ship fires on its own; all you choose is where it is.


![]()
The car drives itself and slowly speeds up; your palm steers it across the road.
There are two ways:
| on the table | in your hand |
|---|---|
| The phone lies sideways, screen up; your palm moves up and down 5–10 cm above the table beside it | The phone is in one hand; the other palm, edge down, moves 5–15 cm from the end with the charging port |
A game lasts a few minutes: the pace keeps growing, and a hand held in the air gets tired too — that's part of it.
Skins in both games: the menu's «◀ ▶» arrows step through the skins, a tap in the middle opens the «Skins» screen. There you leaf through the skins and build a playlist with «+ ADD TO PLAYLIST» / «- REMOVE»; it is shown as a line under the card. One skin in it — you always play in it; several — they change during the game, in turn or at random, after the time you set (15–120 s, or 25–40 s at random). The «All in a row» tick turns them all on; untick it and your playlist comes back.
The pause in both games: go on, start over (straight away or with a new fit), end the game, exit to the menu. Beside them are the skin, the graphics (HD or pixels) and the sounds "- SOUNDS ▮▮▮▮▮▮▯▯ +": eight levels, a tap in the middle switches them off and on. The skin and the graphics can be changed in the middle of a game. If the game's own sounds get too loud for the microphone, it turns them down by itself.
Settings (the games' screen): sounds; graphics (chosen in the game, always HD or always pixels); the probe band (chosen before a game, always wide or always normal); auto-calibration; room following (for the manual calibration); advanced probe settings (only in the Android app — the microphone, the speaker, the volume).
Again after a game — straight to the countdown with the same calibration; "Recalibrate" beside it. If a palm was over the phone while the empty room was measured, the room is measured once more.
Room following (off by default): the manual calibration, but the room is learnt during the game too, as in auto-calibration.
Auto-calibration (off by default): play one game the usual way (with the waving), then switch it on. From then on the game gets ready in a couple of seconds with no waving — it takes the range and the middle of your last manual calibration for this probe band (the middle 6 mm farther from the phone, so the bottom of the screen doesn't end up at the port; the calibration's date is in the settings), shows a try-out flight ("Play" or "Recalibrate"), "Again" goes straight to the countdown, and the echo processing keeps learning the room during the game — a noisier room or a thing put down near the phone doesn't take the palm away. The screen itself is not refitted to the palm in flight: every try at that (1.10–1.13) steered worse. If it steers worse, switch it off, play with the manual calibration, switch it on again.
If something's wrong: a tap on the version on the games' screen reloads the page, and a long press opens "Logs". They record what the phone heard and can be attached to an issue.
iPhone plays well in Safari.
On Android a lot depends on the phone itself: how well its speaker and microphone pass 16–20 kHz. In a browser the page can't choose which microphone records, and doesn't know how loud the phone plays. That is what the app (android/) is for. Inside it shows the same site, so the games in it are always current, but it records and plays the sound itself:
With the app and the wide probe a Mi 9 Lite (from 2019) plays like an iPhone. The notes games send to the server show the palm heard on Galaxy S25, Pixel 8 Pro, Nothing Phone (3a), realme GT Neo 5 and Honor 600 Lite. A OnePlus 13 is poor on the table but almost as good as an iPhone in the hand. A Redmi Note 10S hears the palm reliably only within about 10 cm. A OnePlus 9 Pro is too quiet. The game works out which end of the phone your hand should be at, and says so if it can't hear the palm. Details: docs/design.md, section 2.
Each game has its own tables: today, this week and all time. The first time a game makes a table, it asks for a name (Latin letters, digits, _). A transfer code moves your name and scores to another browser or phone.
Bots play in the tables too — about 40 players the server plays itself, from beginners to strong ones, so there is always someone to beat. Their games are real — the game's bot, by the same rules — and the strongest stay a little below the best person. The «PEOPLE ONLY» tab over the table leaves them out.
What goes to the server is not a score but the game itself: the layout number and the palm height at every step, a few KB. Both games are deterministic — even steps 60 times a second, random numbers from a seed, no transcendental functions. So the server replays every game with the very same code (src/13_core.js for SonaFly, src/14_race.js for SonaRace) and keeps the score only if it repeats.
A player is a random key kept on the phone; the server stores only its hash. With each game goes a short note on the phone: iOS or Android, the browser, the model (on Android), which probe, the volume, how well it heard the probe. That shows where the sonar works. The server stores no user agent string and no IP. It is plain Node + SQLite, in server/.
lab/HANDOVER.md (Russian).| path | what's inside |
|---|---|
src/ | the games' source in numbered parts; build.py joins them into one file, game/play/index.html |
game/ | the site: index.html sends visitors into the games, play/ is the games (built file, icons, manifest, service worker), font.js, robots.txt |
server/ | the high-score server (api.sonaroids.app): server.js, the bots (bots.js, botplay.js), backups, stats, systemd unit, Caddy and nginx configs. Install: server/README.md, in Russian docs/ru/server.md |
android/ | the Android app: a WebView over sonaroids.app/play/ with its own sound, volume, file saving and self-update. Built on GitHub Actions into the latest release (android/README.md, in Russian) |
tests/ | the games' and the server's checks: sh tests/run.sh |
lab/ | the sonar lab: the test app lab/app/sonar_lab3.html, its sources, benches and recordings; notes in Russian |
hands/ | make_hands.py draws the HD hands of the getting-ready pictures into src/42_hands.js (with the promo GIF's hand, promo/promo_hand.py) |
font/ | the 5×7 pixel font (Latin and Cyrillic): font5x7.txt is the drawing, make_font.py packs it into game/font.js |
promo/ | the GIFs and pictures in this README, taken from the real games (see below) |
docs/ | the design document design.md (Russian original in docs/ru/) and the server install guide in Russian |
src/)| part | what it does |
|---|---|
00_head.html, 99_end.html | the page around the script |
10_worklet.js, 11_dsp.js | microphone frames and the echo processing — the same code as in the lab |
12_tune.js | fitting the screen to the palm's range |
13_core.js | SonaFly: the flight and its rules; deterministic, with the rules tag the server checks |
14_race.js | SonaRace: the road, the cars, the gifts and the rules; deterministic, with its own rules tag |
20_sonar.js | speaker and microphone: the probe, side check, levels and volume, getting ready; the Android app's own sound |
21_log.js | setup and game logs (WAV + JSON) for the lab's tools |
22_sfx.js | the game's sounds — all below 6 kHz, out of the probe's way |
23_net.js | the high-score client: sends a game and the name, reads the tables |
30_lang_en.js, 31_lang_ru.js | interface texts |
40_gfx.js, 41_sprites.js, 42_guide.js | drawing, pixel sprites, the drawn phones of getting ready and the instructions |
42_hands.js | the hands of those pictures in HD, as pictures — made by hands/make_hands.py |
43_skins.js, 45_hd.js, 46_vecskins.js, 47_pixskins.js, 48_sizes.js | SonaFly's skins: pixels, HD, shape skins; things drawn the size the game counts them |
48_racehd.js, 48_racecandy.js, 48_racenote.js, 48_racepirate.js | SonaRace's candy land, notebook and pirate world, in HD and in pixels |
48_mix.js | the skins' list: which are on, how often and in what order they change in a game |
49_main.js | the screens, the menus and the game loop |
python3 build.py # src/ → game/play/index.html (python3 font/make_font.py after changing the font)
python3 build.py --icons # redraw the Home Screen icons (needs Pillow)
sh tests/run.sh # the games and the server (~9 min: the real-time ones alone, then the rest two at once)
cd lab && sh tools/run_all.sh # the sonar lab
Node 22.13+ (the server uses node:sqlite) and Python 3. The screen and full-game checks play both games in headless Chromium with a synthetic microphone and a fake high-score server. They need Playwright and are skipped without it. On every check bots drive over a hundred SonaRace races to keep a race's length and difficulty where they were tuned. GitHub Actions runs the checks on every push to main and publishes the site only if they pass.
The version is in VERSION: it shows on the games' screen and goes into the logs. CHANGELOG.md says what each version changed and why. When a change alters play, the rules tag in src/13_core.js or src/14_race.js changes too, and the server is updated together with the site.
All taken from the real games in headless Chromium (needs Playwright):
PROMO=mix NODE_PATH=$(npm root -g) node promo/make_promo.js && PROMO=mix python3 promo/make_promo.py # the GIF at the top (the skin playlist): promo/sonaroids_skins_en.gif, _ru (+ *_full.gif, 800×450)
NODE_PATH=$(npm root -g) node promo/make_promo.js && python3 promo/make_promo.py # the earlier GIF, four games one after another: promo/sonaroids_en.gif, sonaroids_ru.gif
PROMO=pixel NODE_PATH=$(npm root -g) node promo/make_promo.js && PROMO=pixel python3 promo/make_promo.py # the same with the games in pixels: promo/sonaroids_pixel_*.gif
NODE_PATH=$(npm root -g) node promo/make_shots.js # the games' pictures: promo/games.png, sonafly_skins.png, sonarace_*.png
sh promo/make_all.sh # the older pixel GIFs (table and hand)
The GIFs also need numpy, Pillow and ffmpeg. The palm in them is drawn by code (promo/promo_hand.py); the games on the screen really play, steered by that same palm.
MIT — see LICENSE.
HTML
76.1%
JavaScript
16.7%
Python
3.9%
Java
2.6%