Nordic Skating
Live — https://pavelsafronov.gitlab.io/nordic-skating/ · Source
An endless skate across an endless lake of thin black ice, for a phone. The ice sings under the blades the way real black ice does, and its pitch tells you how thin it is. Leave it alone and it skates itself; steer, and you choose where the lake forks and how close to the thin ice you dare go.
Inspired by National Geographic's Hear the Otherworldly Sounds of Skating on Thin Ice, in which Mårten Ajne skates a small lake outside Stockholm.

Status
Step 5 of 6, and a good deal since. The lake runs on for ever between snowy, wooded shores, forking every few kilometres, pinching to narrow passes, opening out until no shore can be seen, and dotted in places with islets of reeds to weave between. The skater skates it, and the ice sings under every stride at a pitch set by how thick it is there. Thin ice is black, sings high and cracks under you. Nobody falls through: how thick the ice is decides only how it sounds.
Every visit is to a different lake, one of a million, by day, at dusk, at night under the Milky Way, or under the northern lights; in clear air, in falling snow, or in a blizzard; with, some nights, a comet, and some days an aeroplane drawing its trail.

- ✅ Scaffold: build, tests, deploy to GitLab Pages.
- ✅ An endless lake, skated on autopilot, with steering.
- ✅ Measure the reference video, then synthesize the ice sound from the ice's thickness.
- ✅ Ice zones that fall out of the physics, and falling through.
- ✅ Forks in the lake, narrow passes, and stretches of open sea.
- Game controller, then multiplayer. Postponed: PLAN.md has what comes first.
Controls
The button at the top right turns the sound on and off, as does the M key, and shows which it is: a speaker, or a speaker crossed out. Sound cannot start by itself: a browser lets a page make sound only after it has been touched, so until then the speaker is crossed out, and the first tap or key anywhere starts it. Turning it off is remembered for next time. On an iPhone the ring/silent switch also silences it.
Do nothing and the skater follows the middle of the lake, round every bend and down one side of every fork, for as long as you leave it.
| To | Touch or mouse | Keyboard |
|---|---|---|
| Steer left or right | Drag sideways from wherever you touch; or tilt the phone, with Tilt on | ← → or A D |
| Tilt to steer, on or off | The Tilt button at the bottom left (phones and tablets only) | |
| Turn right round, or circle an islet | Hold the steer over | the same |
| Choose a way at a fork, or a side of an island | A touch towards it on the way up | the same |
| Sound on or off | The button at the top right | M |
| Go faster or slower | The pace button at the bottom right: each press is the next pace up, and from the fastest back to the slowest | ↑ ↓ or W S |
| Go to another lake | Randomize, at the top left | R |
At the top left, About opens a box that explains the app and its controls, and Settings one that changes it. The lake waits behind either, skater and sound both, until it is closed: by its button, by Escape, or by a touch outside it.
Randomize goes to another lake, picked at random, at once and without reloading the page. The browser's Back button returns to the lake you left, and Forward goes on again.
Install
The app can be put on a phone's home screen, where it has its own icon, opens full screen without the browser's bars, and works with no network.
- Android (Chrome): the menu, then "Add to Home screen" or "Install app".
- iPhone (Safari): the Share button, then "Add to Home Screen".
Nothing is fetched once it is installed: the whole app is kept on the phone and the lake is computed. A new version is taken up the next time it is opened with a network.
Settings
Each takes effect as it is touched, behind the box, and is remembered for next time.
| Setting | Choices | To begin with |
|---|---|---|
| Time of day | Surprise me, Day, Dusk, Night, Aurora | Surprise me: whichever the lake comes with |
| Weather | Surprise me, Clear, Snow, Blizzard | Surprise me: whichever the lake comes with |
| Pace | Meander, Cruise, Brisk, Racing, Olympic | Cruise |
| Impossible ice | on or off | off |
| Lake | Another lake (the same as Randomize); a link back to this one | a different lake every visit |

Tilt to steer
On a phone or tablet there is a Tilt button at the bottom left, showing a phone held in a hand when tilt is on and a phone by itself when it is off; anywhere else it is not shown. Turned on, which is remembered, the phone is leaned left or right like a wheel, about the line the skater looks along: more lean, tighter turn, full steering at 28°. Within 4° of level counts as level, since nobody holds a phone dead level, and held level the skater skates itself. It works held upright or on its side, and dragging still works alongside it.
While tilt is on the screen is stopped from turning with the phone, since leaning a phone over is exactly what makes its screen flip. A browser does that only for a page that fills the screen: the installed app already does, and in a browser tab the page goes full screen while tilt is on and comes out of it when tilt is turned off. An iPhone will do neither for a web page, so there the phone's own rotation lock, in Control Centre, is the way.
An iPhone asks leave for this, each visit, at the first touch. If it has been refused
it is not asked again, and the app says so where its hint is: Settings, Safari, Motion &
Orientation Access turns it back on.
Browsers give a page the phone's tilt only over HTTPS, which the live site is and
npm run dev on a phone is not, so it cannot be tried on a phone from the dev server.
A different lake every visit
A lake is a number from 1 to 999,999, and everything about it follows from the number:
its bends, widths and forks, its ice, its trees, the time of day and the weather it
comes with, which way its wind blows, and what is in its sky.
Opening the app picks a number, and so do Randomize and reloading the page: each is a
new lake under a new sky. Settings shows the number and a link to it, and ?seed=1234
on the address asks for a lake by number, which is how to come back to one or send it
to someone.
Which lake a page is showing is kept with its place in the browser's history, not in
its address. That is what lets Back return to the lake that was there while a reload,
which is someone asking for something new, gets a new one. Randomize takes any
?seed= off the address, since it no longer says which lake this is.
Times of day
| Time | The sky | How often a lake comes with it |
|---|---|---|
| Day | Flat, bright winter noon | 35% |
| Dusk | An orange horizon under deepening blue, and the first stars | 20% |
| Night | Moonless and clear: the Milky Way and thousands of stars | 27% |
| Aurora | The same night under slow green curtains, violet where they reach highest | 18% |
The sky is painted by a shader (src/render/sky.ts), and each time has its own light:
the snow is orange at dusk, blue at night and faintly green under the aurora, and the
milkiness of thick ice shows as much as there is light to show it. ?time=night on the
address asks for a time whatever the settings say.

What else is in the sky
- Shooting stars, at night and under the aurora: one every quarter of a minute or so, somewhere in the sky, gone in under a second.
- A comet, over one night lake in three: ahead and low, with a tail.
- An aeroplane, over two day or dusk lakes in three: a bright speck crossing the sky ahead in four or five minutes, drawing a trail that widens and fades behind it, white by day and pink at dusk. When it has gone, a while later another comes.

Weather
| Weather | Snow | How far you can see | The sky | Wind | How often a lake comes with it |
|---|---|---|---|---|---|
| Clear | none | 600 m | as the time of day has it | none | 64% |
| Snow | falling quietly | 420 m | mostly cloud | 0.5 to 3 m/s | 26% |
| Blizzard | thick, and driven | 200 m | all cloud | 7 to 16 m/s, in gusts | 10% |
Snow that falls lies, in drifts (src/render/drifts.ts): long pale streaks on the
ice, all running the way that lake's wind blows, a few of them under falling snow and
many in a blizzard. A drift is the lake's bad ice. In the middle of one a skater is
dragged at four times as hard as on ice and slows to about half speed; the ice under
it does not ring to a strike or sing to the glide, and the blade shushes instead. Bare
ice between the drifts is as fast and as loud as ever, so in a blizzard there is a
reason to pick a way.
Snow in the air is a box of flakes that stays round the skater (src/render/snow.ts): they fall,
the wind carries them, and they stream past as the skater moves, which is most of what
makes a blizzard feel fast. Each lake's wind blows from a direction of its own. The wind
can be heard, louder and higher in its gusts. Cloud hides the stars, the aurora and
whatever else is up there, and takes the colour out of a sunset. ?weather=snow on the
address asks for a weather whatever the settings say.

A game controller works as well: either stick or the direction buttons steer, up and down on the direction buttons or the shoulder buttons change pace, Y or triangle goes to another lake, and X or square turns the sound on and off. It needs no setting up beyond being connected, though a browser shows a page a controller only once one of its buttons has been pressed.
Steering turns the skater: the harder the steer, the tighter the curve, up to a circle 16 m in radius, with a moment to lean into it. Held over, the skater turns right round and can skate back up the lake, or round and round; that needs open water, about 24 m of it on the side being turned towards. A shore cannot be reached; near one the skater is turned to run along it.
The autopilot does not skate a straight line. It carves long, slow S-curves down the lake, 6 m either side of wherever its line is centred.
Steering changes the autopilot's plan; it does not interrupt it. Let go, and the skater carries on the way you sent them, from where they had got to: the autopilot's line is re-centred there and its next curve begins in your direction, wide at first if you sent it off at an angle, and settling back to its usual 6 m over about twenty seconds. It does not return to where it was, or to the middle of the lake. Steer left out of a right-hand curve and you have a left-hand curve.
The speed breathes with the stride: each push adds about a seventh of it, and the glide after it gives that back. Strides are of different lengths, and no two the same, so neither are the speeds.
Pace
| Pace | Speed | Strides | Turning circle | |
|---|---|---|---|---|
| Meander | 3.5 m/s, 13 km/h | 2 to 4.5 s, long and lazy | 8 m | Standing tall. Tight enough to circle an islet closely |
| Cruise | 7 m/s, 25 km/h | 1.4 to 3.2 s | 16 m | Where it starts |
| Brisk | 10 m/s, 36 km/h | 1 to 2.4 s | 22 m | |
| Racing | 12.5 m/s, 45 km/h | 0.8 to 1.9 s | 31 m | |
| Olympic | 15 m/s, 54 km/h | 0.6 to 1.4 s, short and quick | 45 m | Down in a crouch. The pace of a long-track speed skater in a 1500 m, kept up for ever |
A change of pace is worked up to, or eased down to, over a few strides. Everything
else follows the speed. A faster skater is held to half a g sideways and so turns in a
wider circle; looks further ahead, and steers round an islet from further off; keeps
further from the shores, which in ordinary water leaves a racer little room to weave;
swings wider on the autopilot's line; sees a wider view, which is most of what makes
speed look like speed; and hears the air rush past. The pace is remembered, and
?pace=olympic on the address asks for one for a visit.
The screen is kept awake while the page is open, where the browser allows it.
At a fork the lake parts round an island. A touch of steering one way, from 200 m before the fork, is taken as asking for that side, and the autopilot does the rest. With no touch it takes the side it is already over towards, and down the middle, the side the fork prefers. Until the island begins, a little over 200 m after the fork, you can still change, by steering the other way. After that there is land between, though a skater who turns back can go round its tip.
The ice
How thick the ice is decides how it looks and the note it sings. It does not decide whether it holds: for now nobody falls through, and the whole lake is safe to skate.
The zones are not chosen. They are the three thresholds of Gold's formula for what a
sheet of ice will carry (src/ice/strength.ts), worked out for an 80 kg skater, and the
note is the physics under The sound.
| Ice | Thickness | Looks | Sings at | What happens |
|---|---|---|---|---|
| Impossible, if turned on | 0.8 to 2.2 cm | bright pink, a colour nothing on a lake is, strongest where it is thinnest | 1390 to 3800 Hz | Nothing: it sings, very high, and never cracks. Real ice this thin would not hold anyone |
| Cracks | 2.2 to 3.4 cm | nearly black | 900 to 1390 Hz | It cracks around you: more often and nearer the thinner it gets |
| Sings | 3.4 to 4.8 cm | dark grey | 630 to 900 Hz | Sound ice near its limit. The ice of the film is here, at 4.1 cm |
| Safe | over 4.8 cm | milky grey | below 630 Hz | Nothing. The thicker, the lower and plainer the note |
Of the ice a skater can reach, about 30% cracks, 35% sings and 35% is safe.
Impossible ice is a setting, and off to begin with. Off, the thinnest ice is 2.2 cm, the thinnest that is real, in patches that are on the point of giving way all the way across and crack every second or so. On, those patches go on thinning to 8 mm at their middles and fall silent: about 4% of the ice a skater can reach. They are bright pink, as bright at midnight as at noon: lit from within, in a colour chosen because nothing on a lake is that colour.
Falling through is written and switched off (RULES in src/skater.ts). Switched on,
ice under 2.2 cm gives way and the skater is in the water for four seconds. It needs one
thing first: a line the autopilot can be sure of, since nothing now keeps thin ice off
its path.

How the lake works
- The lake is a function, not a map (
src/world/lake.ts). Where a channel's middle is, how wide it is and how the land rises from its shores are all computed from the distance skated and a seed. Any stretch can be made on demand, the same on every device, and nothing is stored. - It forks. A channel runs from the fork that made it to the fork that ends it: the first fork is 700 m from the start, and each one after comes 1.8 to 4.2 km after the last. A fork's two channels start as the one channel and draw 180 m apart over 600 m, which is always more than both are wide, so there is always an island. Each has a width and forks of its own from there on. Both are drawn for 1.2 km, and then only the one taken: the other is out of sight behind the island by then.
-
It changes character over kilometres. About 70% of the way it is ordinary water, 12 to 44 m from the middle to a shore. About 10% of the way it pinches to a pass 5 m to a side. And now and then it opens until both shores are beyond the haze, 700 m off or more: a stretch of sea. Near a fork it is always ordinary, so that a fork reads as one.
-
It has islets. Tufts of reeds on humps of frozen ground, 5 to 15 m across, standing in the ice as they do in the film, where the skater weaves between them. They come in beds: about a quarter of the lake's half-kilometres have some, one every 50 m or so, and the rest have none. Each has at least 9 m of ice between it and a shore, and there are none in a narrow pass or near a fork. The autopilot goes round them; a skater steering straight at one is turned aside at the last moment.
-
It has long islands. Low banks of reeds, 180 to 480 m long and up to 32 m across, standing in the middle of a channel with water both sides: the channel parts round one and is one again beyond it. The water either side is as wide as the channel would have been, so the lake swells where an island is. A touch of steering on the way up to one chooses the side; with none, the island has a side it prefers. About one every 5 km, and none near a fork or in a pass.
- Its ice is cracked the way ice cracks (
src/ice/cracks.ts). A few long cracks run nearly straight for tens of metres; later ones start from the side of an earlier one, leave it at a wide angle, and stop where they meet another, since a crack cannot cross a crack. So the cracks are a network of long runs joined at T's, with finer ones between, and each is painted as it looks in clear ice: a bright edge at the surface and its face going down into the ice beside it. - It keeps the marks made on it (
src/render/marks.ts). The skates leave a line, and each push a scratch to one side and then the other; on ice thin enough to crack, a crack opens where each near one is heard from, and a push on such ice starts more from under the skate. A crack is a star of a few arms that fork, and it runs out from its middle over a moment. Every mark is a hairline, a pixel wide however near. They stay where they were made, so a skater who turns back or circles an islet comes on their own tracks, as in the film; the lake remembers the last few thousand stretches of line and forgets the ones before.




- The skater has a place, a direction and a speed (
src/skater.ts), and steering is turning. The autopilot steers by the same means and within the same limits, aiming at a point 18 m ahead on its wandering line, so every path on the lake is an arc. The skater may point any way at all, back up the lake included; left alone, the autopilot turns them down it again. - The skater is kept on the ice. They have the width of the channel less 2 m at each shore. Within 24 m of the edge of that they may point less and less towards it, down to not at all at the edge. Where the lake narrows faster than a skater could steer clear, their room starts closing 200 m early, whichever way they are going. Out at sea they may go no further than 150 m from the middle.
- Thickness is a function too, and is painted on the ice from a small map of the 768 m
around the skater that is redrawn every 32 m (
src/render/ice.ts). - The skater never leaves the origin of what is drawn. The shores and islands are
built 100 m at a time ahead of the skater, thrown away behind, and placed relative to the skater
every frame (
src/render/shores.ts); the ice is one sheet whose texture slides the other way (src/render/ice.ts). So nothing drawn is ever a large number, and the picture is as sharp 10,000 km down the lake as at the start.
The sound
The sound is synthesized in the browser. None of it is a recording.
The physics
A sheet of ice floating on water carries bending waves, and they are dispersive: the
higher the frequency, the faster it travels. The two sounds of thin ice follow from that
(src/ice/acoustics.ts).
- The singing note. A bending wave slower than sound in air cannot radiate into the air at all. The ice gives off sound most strongly at the one frequency whose bending wave travels exactly at the speed of sound in air, where the wave in the ice and the wave in the air keep step. That frequency is very nearly 30.3 Hz·m divided by the thickness: 4 cm of ice sings at about 760 Hz, 3 cm at about 1010 Hz, 6 cm at about 505 Hz. The pitch is a thickness gauge: the higher it sings, the thinner the ice.
- The chirps. A crack is every frequency at once. From some way off the high ones arrive first and the low ones after, ending on the note: a falling "pew". The further the crack, the longer the fall — about a third of a second from 400 m.
The model is from Romeyn, Hanssen, Ruud and Johansen, Sea ice thickness from air-coupled flexural waves, The Cryosphere 15, 2939–2955 (2021), who took the sound of the very film this app is modelled on and read about 725 Hz and 4.3 cm of ice from it. With textbook values for freshwater ice and nothing tuned, the model here gives 4.2 cm for 725 Hz.
What the film measures
npm run analyse-video measures a local copy of the film (it is not in this repository):
| Measured | Used for | |
|---|---|---|
| The note | median 740 Hz; 664–826 Hz over the middle 90% | Ice 4.1 cm thick, 3.7–4.6 cm; the middle of the lake's range of thickness |
| Time between skate strikes | median 0.66 s | Not used: at that rate the view rocks from leg to leg without rest. Strides here are long glides of 1.4 to 3.2 s, each a different length |
| How fast a ring dies | 36 dB a second | The ring's decay: to 1/e in 0.24 s |
The chirps cannot be checked against the film. Three ways of doing it were tried, and the last is still in the tool: split each burst of sound into frequency bands, time when each band arrives, and see whether higher bands arrive earlier by as much as the model says. Of 122 bursts, 119 show no spread to measure: every band arrives within 30 ms of every other. The other three do not agree with each other or with the model. The likely reason is that the film's microphones were close to the skater, and a sound from close by has had no distance to spread out over. So the film gives the note and not the chirp. The chirps here are the model's, which is the paper's, and the paper measured the spreading directly, with instruments on the ice.
What is synthesized
src/audio/iceSound.ts, from noise and sine waves only:
- The ring: at each stride, every 1.4 to 3.2 s, a skate strikes the ice, which answers with a narrow band of noise at its note, starting a little high and falling onto it, then dying away.
- The hum and the scrape: the gliding blade keeps the note faintly alive and hisses.
- The wind, when there is one: low, loose noise, louder and higher in its gusts.
- Snow under the blade, in a drift: a soft, low shush in place of the ring, the hum and the scrape.
- The cracks: every few seconds the ice cracks somewhere between 40 and 600 m off, and the chirp for that distance and that thickness arrives. The falling chirp is a sine wave and stops the moment it lands on the note; what rings on after it is the ice, in the same band of noise as after a strike. (Left to ring by itself the sine was a clean, struck, high note, like a sonar ping.) On ice thin enough to crack under the skater the cracks come from a few metres away, and faster the thinner it is, up to one every second or so. Impossible ice does not crack at all.
npm run sound-check renders twelve seconds of it without playing it and draws its
spectrogram: six seconds on the film's 4.1 cm, then onto ice thinning to 2.8 cm, with
three cracks.

Develop
Needs Node 22.
npm install
npm run dev # dev server on every interface, with a QR code for a phone on the same Wi-Fi
npm test # vitest
npm run build # typecheck, then build into dist/
npm run preview # serve dist/
npm run screenshots # build, then photograph the app at phone size into docs/screenshots/
npm run sound-check # render the synthesized sound and draw its spectrogram into docs/sound/
npm run analyse-video # measure a local copy of the reference film
npm run icons # remake the home-screen and tab icons from assets/skate-shoes.png
On the address: ?seed=1 asks for lake number 1, ?time=night for a time of day (day,
dusk, night, aurora), ?weather=snow for a weather (clear, snow, blizzard), ?pace=olympic for a pace
(meander, cruise, brisk, racing, olympic),
and ?s=2500 starts that many metres down the lake. The
screenshots use all three, so that they are of the same thing every run.
The screenshots and the sound check need Playwright's Chromium once:
npx playwright install chromium. The sound check and the analysis need ffmpeg.
Deploy
.gitlab-ci.yml tests and builds every push, and publishes the default branch to
GitLab Pages. Asset paths are relative (vite.config.ts), so the build works at
/nordic-skating/ or at the root of a unique Pages domain without configuration.
Static files go in static/, not public/: GitLab Pages needs its artifact to be
named public, and a public/ already in the repository would collide with it.
Licence
MIT, for everything here but the icons. What it builds on is credited below.
Attribution
- The app's icon and the pictures on its buttons are by Icongeek26, from Flaticon, used under the Flaticon free licence, which requires attribution. The credit is in the app's About box as well as here. None of them is covered by this project's MIT licence.
- Of those, the app's icon is a skate. The artwork is
assets/skate-shoes.png; the icons instatic/are made from it bynpm run icons, which uses only its shape: pale on the colour of black ice for a home screen, and as it was drawn, black on a see-through background, for a browser's tab (white where the browser is dark). The same skate is on the pace button. - And the pictures on the sound button, a speaker and a speaker crossed out, are
assets/sound-on.pngandassets/sound-off.png. On a button only a picture's shape is used, in the colour of the button's words. - The pictures on the Tilt button, a phone held in a hand for on and a phone by itself
for off, are
assets/tilt-on.pngandassets/tilt-off.png. Whether these two are by the same author has not been confirmed; if they are not, the credit here and in the About box needs their author added. - three.js — MIT licence.
- simplex-noise — MIT licence.
- Workbox, by way of vite-plugin-pwa — MIT licence. It is the service worker that keeps the app on the phone.
- The physics of the sound: Romeyn, R., Hanssen, A., Ruud, B. O., and Johansen, T. A., Sea ice thickness from air-coupled flexural waves, The Cryosphere 15, 2939–2955, 2021 (CC BY 4.0).
- What ice will carry: Gold, L. W., "Use of ice covers for transportation", Canadian Geotechnical Journal 8, 170–181, 1971.
- The National Geographic video above is the reference this is modelled on. It is copyrighted, and neither it nor any audio taken from it is part of this repository or the app: it is measured locally, and the app's sound is synthesized from those measurements.