9io.ai / Blog

How to port Swift to JavaScript and check it with parity tests

What it took to port Swift to JavaScript for Football Kick’s browser game, and how parity tests compare the port with the Swift engine.

Key takeaways

  • Swift’s Double and JavaScript’s Number are the same IEEE 754 doubles, and basic arithmetic rounds identically.
  • Library functions such as exp, sin and atan2 can round differently in each JavaScript engine and C library.
  • On one Mac, V8 and the C library disagreed on 3.9% to 17.9% of results, by one ulp.
  • A fixed 1/600 s step and SplitMix64 in BigInt make each kick deterministic in both languages.
  • Printed-precision fixtures notice changes of a few parts in ten million; one-ulp differences show only at ties.

The browser game at footballkicks.9io.ai runs Football Kick’s own engine. We ported KickKit, the Swift package behind the iPhone game, to one JavaScript module, and the port reproduces 13 kick timelines and a 3,600-penalty table written by the Swift engine. If you port Swift to JavaScript and need both sides to agree, basic arithmetic is the safe part. Swift’s Double and JavaScript’s Number are the same IEEE 754 doubles, and addition, multiplication, division and square roots round identically in both.

What can still pull them apart is the order you write operations in, the random numbers, and maths library functions such as exp, sin and atan2, which each engine is free to approximate. On one Mac, seven of Node’s Math functions disagreed with the system C library in the last bit on 3.9% to 17.9% of a million inputs each. Our parity tests compare printed timelines, so they mostly can’t see that. In an experiment for this post they started failing only when every library result was off by at least three parts in ten million, apart from one fixture that sits on an exact rounding tie.

This post covers what we ported, how the fixtures work, what has to be deterministic, how often the two sides disagree and what the tests can detect. It ends with a checklist for porting numeric code between languages.

What we ported to JavaScript, and what stays in the app

Football Kick is a one-finger football game for iPhone and iPad, made for iPhone Duo; the launch post covers how it plays. Its engine, KickKit, is plain Swift with no UI framework, so it builds and is tested on Linux as well as on Apple’s platforms.

The port is one dependency-free ES module, football-kick.js, of 224,610 bytes. Importing it touches nothing, and mount(container, options) builds the canvas, HUD and end card inside the element you pass. It keeps the engine’s units and axes, in metres from the middle of the goal line, and each section of the file names the Swift file it ports.

From KickKit it carries everything that decides a penalty, from the vector maths and the random number generator to ball flight, the net, the goalkeepers, the kick solver, the swipe reader, scoring and the modes, plus KickKit’s cameras and renderer. From the app it carries the play session, which handles input, slow motion and replays, along with the HUD and the end card.

The browser has no free kicks or walls, no World Tour, shootouts or Daily Kick, no sound or haptics and none of the iPhone Duo views. As of 1 November 2026 the app is on its way to the App Store, and the browser plays Just Kick, Penalties (five kicks) and Streak for five minutes a day with no sign-up. Just Kick’s levels move the ball back from the penalty spot, as far as 30 metres. In the app, Streak mixes in other kicks after three goals; in the browser it stays on the penalty spot.

The browser game on footballkicks.9io.ai just after a goal: TOP BINS! as the keeper dives the wrong way

How the parity tests check the port against Swift

The Swift engine writes the fixtures. KickKit ships a command-line tool, kick-cli. Its shot command sets up one kick from its arguments (aim, pace, bend, distance, keeper rating and seed) and lets the ball sit on the spot for half a second. Then it strikes the ball, runs 120 Hz frames until the shot is over, and prints the launch and every event with its time and position. Its keepers command plays 60 penalties for each of six keeper ratings, five target zones and two paces, 3,600 in all, and prints save percentages.

That output lives in web/test/parity.test.mjs as arrays of strings. The test builds each kick the same way in JavaScript, prints the same lines in the same formats and asserts that the arrays are deeply equal. This is one of the 13 kicks, aimed 5.5 metres left of centre from the penalty spot.

kick speed 28.3 m/s, elevation 8.5°, spin (-4, -2, -0)
  t=0.000  strike 28.3 m/s  at (0.00, -11.00, 0.11)
  t=0.487  DECIDED wide at (-5.58, 0.81) bend -0.08  at (-5.60, 0.20, 0.81)
  t=0.647  boards 20.2  at (-7.26, 3.49, 0.51)
  t=0.770  bounce 3.8  at (-8.12, 2.75, 0.00)
  t=1.202  bounce 2.3  at (-10.22, 1.02, 0.00)
  t=1.463  bounce 1.4  at (-11.27, 0.17, 0.00)
result: wide

The others cover goals, the post, shots over the bar, bent shots, a 25-metre strike, and keepers of different ratings saving, parrying and getting beaten.

The printed precision is the tolerance, and the parity file has no other. Times match to the millisecond, positions to the centimetre, speeds to 0.1 m/s, spin to whole radians per second and save rates to whole percentages. Printing has traps of its own. The -0 at the end of the first line is the side spin of a shot with no bend, minus zero times the maximum spin. Swift’s String(format:) prints its sign, while JavaScript’s toFixed adds a minus only when the value is below zero1, so the test’s formatter puts it back. The test also drops side-netting contacts, which print a line every step as the ball rolls along the net.

The rest of the suite mirrors KickKit’s unit tests for swipes, physics, keepers and game rules, with the same inputs and tolerances. These check properties, such as the kick solver landing within 0.04 m of its aim in 108 combinations, without comparing output with Swift. A module test plays a whole game against a fake DOM and a recording 2D context.

The suite runs with node --test web/test from the repository root and needs no install. CI runs it with Node 22 on Ubuntu, beside the Swift tests in a Swift 6.1 Linux container. On 30 October 2026 we ran it three times on a Mac with Node 25.8.2, and all 60 tests passed each time in 4.5 to 4.6 seconds, 3.1 to 3.3 of them on the 3,600 penalties.

Deterministic physics with a fixed timestep and seeded randomness

In the browser, one kick can be simulated three times. When you swipe, the game plays a copy of the shot ahead at 60 Hz, and if the copy is heading for the woodwork, a save or the top corner, it slows time to 0.3× around the decision. The live kick then plays at the browser’s frame rate, and after the best goals a replay restarts from a copy taken before the strike, at 0.45×. All three have to end the same way, or the slow motion would start at the wrong moment and the replay would show a different goal.

A fixed timestep makes that hold2. Shot.advance(dt) adds the frame’s time to an accumulator, capped at 0.1 s, and runs 1/600 s steps while a whole step remains, so slow motion only changes how many steps run per frame. Both test suites check it: a kick advanced in uneven frames of 1/60, 1/120, 1/37, 0.3/60 and 1/90 s, and its replay advanced 0.45/120 s at a time, must reach the same outcome at the same crossing point and moment.

The loop runs while accumulator >= step - 1e-12, and the slack matters. 1/600 has no exact binary representation, and 1/120 minus four steps of 1/600 leaves 0.0016666666666666655 s, a hair under one step. Without the slack the first 120 Hz frame runs four steps instead of five, and the simulation stays a step behind from then on. The port copies the comparison, slack included.

Math.random can’t be seeded, so the engine brings its own generator, SplitMix64, with the constants of Sebastiano Vigna’s reference version3. Its 64-bit wrapping multiplications are beyond a JavaScript Number, so the port runs it on BigInt and masks each result to 64 bits. Integer arithmetic is exact in both languages, so the bits match. To turn 53 of them into a fraction, Swift multiplies by the literal 0x1.0p-53 and the port by 2 ** -53. The spec leaves even ** implementation-approximated4, so we checked that Node gives exactly half of Number.EPSILON.

Randomness enters a kick through the keeper’s guess as the ball is struck, the keeper’s reading error as it flies, the speed and angle of a parry, and gusts of wind. The browser seeds each match from the clock, and each kick gets its own seed hashed from the match seed and the kick’s number, so the fourth kick doesn’t depend on how many numbers the first three drew. Crowds, grass and particles use a separate 32-bit generator, so drawing a frame never moves the simulation’s random stream.

The simulation hardly uses BigInt. Replaying the 3,600 penalties, a kick drew between 2 and 86 random numbers, 14 at the median, and one draw took about 90 ns in Node, so a typical kick spends around 1.3 microseconds on them.

Between the two languages, determinism matters only to the tests. The module makes no network requests, and the Daily Kick, the one mode that’s the same for everyone, is app-only.

Keeping the order of operations

Floating-point addition and multiplication aren’t associative, so the port keeps the grouping of every expression in the Swift source. Drag shows why:

// Swift: a -= air * (physics.drag * speed)
a = sub3(a, mul3(air, physics.drag * speed));           // the same grouping
// a = sub3(a, mul3(mul3(air, physics.drag), speed));   // same maths, can round differently

Swift’s dot product is (self * other).sum() on a SIMD3. In Swift 6.2’s standard library, sum() adds the lanes left to right, starting from negative zero5, which rounds the same as the port’s a.x * b.x + a.y * b.y + a.z * b.z. A comment in that source says sum() may later become a tree sum. If it does, the Swift engine’s dot products could change in the last bit and the port’s wouldn’t.

The order inside each 1/600 s step matters as much. The keeper reads the ball first, and that read draws random numbers. Then the ball moves and bounces, and is checked against the goal frame, the keeper, the net and the boards, in that order, before the goal line decides anything. Swap two of those and a shot that clips a post and a glove in the same step could play out differently. A firm parry draws three random numbers, for the speed back out, the sideways speed and the lift, and the port draws them in that order.

The two languages also round halves differently: -2.5 becomes -3 with Swift’s rounded()6 and -2 with JavaScript’s Math.round7. The port uses Math.round where Swift calls rounded(), on scores and wind speeds, which are never negative, so they agree.

Where floating point differs when you port Swift to JavaScript

ECMAScript’s Number type is the set of IEEE 754-2019 binary64 values8, and Swift’s Double is an IEEE 754 double-precision type6. Basic operations are correctly rounded in both, so identical inputs give identical outputs, and the spec requires Math.sqrt to return the correctly rounded square root9, the operation behind Swift’s squareRoot(). The port measures every length as Math.sqrt of a dot product, like Swift’s lengthSquared.squareRoot(), and uses Math.hypot, which the spec leaves approximate, only in drawing code.

Library functions are another matter. ECMAScript lists 23 Math functions, including exp, log, sin, cos, atan2 and pow, whose behaviour “is not precisely specified” beyond boundary cases. Each returns an implementation-approximated value, and the spec recommends Sun’s fdlibm algorithms without requiring them10. Swift takes these functions from the platform’s C library through the Darwin or Glibc module11, so the same Swift code calls Apple’s library on an iPhone and glibc’s in the Linux CI container. Glenn Fiedler’s 2010 notes on floating-point determinism report the same problem in games, with sin and cos results drifting between machines12.

V8, the engine in Chrome and Node, ships its own port of fdlibm13. A build switch replaces its sin and cos with a bundled copy of glibc’s, and V8 14.1’s build files turn it on for builds compiled with clang14, so two builds of the same engine can compute Math.sin with different code. For Math.pow, V8 calls the C library’s pow by default15. We haven’t looked at what other browsers’ engines do.

The port does nothing special about any of this. It calls exp, sin, cos, atan2, atan, asin, pow and log at the same points, with the same arguments, as the Swift code, and ships no maths library of its own. Since the random bits are integers, only the Box-Muller step that turns them into a Gaussian reading error goes through log and cos, and the port relies on the parity tests for everything else.

How often V8 and the C library disagree in the last bit

To put a size on cross-language floating point differences, we compared Node 25.8.2 (V8 14.1) with macOS’s C maths library, called through Python’s math module, on an Apple M4 Pro. Each function got a million random inputs in ranges like the engine’s, compared bit for bit, with gaps counted in ulps (units in the last place). For the first 20,000 differences per function, mpmath at 200 bits decided which side was correctly rounded.

Function Inputs Results that differ Largest gap Correctly rounded when they differ (V8 / C library)
sin -π to π 39,055 (3.9%) 1 ulp 56% / 44%
cos -π to π 43,432 (4.3%) 1 ulp 64% / 36%
atan -10 to 10 57,797 (5.8%) 1 ulp 64% / 36%
log 0 to 1 68,329 (6.8%) 1 ulp 0.1% / 99.9%
asin -1 to 1 86,222 (8.6%) 1 ulp 46% / 54%
exp -10 to 0 97,523 (9.8%) 1 ulp 0.9% / 99.1%
atan2 y and x from -40 to 40 178,990 (17.9%) 1 ulp 3% / 97%
pow(x, 0.9) and pow(x, 1.5) 0 to 1, and 0.6 to 10.6 0 of 2,000,000 none V8 calls the C library’s pow

No result was more than one ulp, the step between neighbouring doubles, from the other side’s. Neither library was always correctly rounded. The C library won at least 97% of the differences for exp, log and atan2, and for sin, cos, atan and asin each side won between 36% and 64%, so a port has no single right answer to match. The four fixed exp arguments in the engine’s step loops gave identical results on both sides.

What the parity tests can and can’t see

We ran the parity tests unchanged with a preloaded module that wraps ten Math functions, from exp and log to atan2 and hypot, and nudges their results.

With every result one ulp up, all 14 parity tests passed. They also passed when a random 10% or 50% of results moved one ulp either way. With every result one ulp down, 13 passed. The wide shot above failed on one character: its crossing printed -5.59 where Swift printed -5.58.

That kick has no side spin and the air is still, so the ball stays in the vertical plane through the spot and the aim point. The engine records the crossing when the ball’s centre is 0.17 m past the middle of the goal line, the post’s radius plus the ball’s. A ball aimed 5.5 m left from 11 m out is then exactly 5.5 × 11.17 / 11 = 5.585 m left, a tie at two decimals. Node computes -5.584999999999999, 9.2 × 10⁻¹⁶ m from the tie, so one ulp in the library results decides which way it prints. The printed bend, the 8.5 cm between that crossing and the launch line, is a tie for the same reason. Of the 454 numbers the 13 fixtures print, those two are the only ones within 10⁻¹⁵ of a tie; the next closest is 3.8 × 10⁻⁵ away.

Then we scaled every library result by 1 + e. Apart from the tie, the fixtures kept passing up to |e| = 10⁻⁷. The first other failure came at e = -3 × 10⁻⁷, when the second bounce of a top-corner goal in the back of the net landed 3 ms later. At 10⁻⁵, six or seven of the 13 kicks failed while the 3,600-penalty table still matched, and at 10⁻⁴ every test failed. One ulp near 1 is 2.2 × 10⁻¹⁶.

A browser whose sin rounds differently from Node’s should therefore play these 13 kicks the same way, unless a value lands on a tie. We haven’t run the suite in Safari or Firefox.

The 77 KB the browser downloads

The module on the live site is byte for byte the file in the repository; the SHA-256 hashes match. As of 30 October 2026 it’s 224,610 bytes, the server sends 76,785 bytes with gzip, and it doesn’t offer Brotli. The page imports the module only when the game comes within 600 px of the screen.

The engine, the part a port has to match, is 90,238 bytes, 40% of the file. Cameras and rendering take 70,718 bytes, and the theme, HUD, end card and play session 63,022. There’s no build step and no minifier. The 76,785 bytes are exactly what zlib produces at level 1, its fastest; at level 6 the same file is 63,688 bytes, 17% smaller.

How we measured

Everything was measured on 30 October 2026 on one Apple M4 Pro running macOS 27.0.1, with Node 25.8.2 (V8 14.1), Python 3.13.1 and mpmath 1.3.0. Nothing in the repository was changed, built or installed.

  • Tests: node --test web/test from the repository root, three runs, with durations from the runner’s own report.
  • Bundle: curl fetched the live module with Accept-Encoding set to identity, gzip and br. We compared SHA-256 hashes with the repository copy, recompressed the file with Python’s gzip module at levels 1, 6 and 9, and split it at its section headers.
  • Steps: the accumulator figures come from running the loop in Node for twelve 120 Hz frames, with and without the slack.
  • Libraries: Python drew a million uniform inputs per function from a fixed seed and recorded the C library’s results, then Node computed Math’s results for the same inputs and we compared the 64-bit patterns. mpmath’s 200-bit results, rounded to the nearest double, judged the differences.
  • Parity sensitivity: a module preloaded with NODE_OPTIONS=--import wrapped exp, log, sin, cos, atan, atan2, asin, acos, pow and hypot, leaving ±0 arguments and non-finite results alone, and parity.test.mjs ran as committed. The 454 printed numbers came from replaying the 13 kicks with the test’s own setup.
  • Random draws: we counted calls to the generator while replaying the 3,600 penalties, and timed 2,000,000 draws after a warm-up, three times, at 89 to 92 ns per draw.

We didn’t run kick-cli or any Swift code, so the fixtures are as committed, and we don’t know which platform’s C library produced them. We didn’t measure glibc, Safari’s or Firefox’s engines, Chrome’s build of V8, or an iPhone.

A checklist for porting a game engine between languages

This is how each part of Football Kick’s engine is checked in the browser port.

Part of the engine Swift source How the JavaScript port is checked
Ball flight, bounces, posts, net, boards Physics/Ball.swift, Physics/Goal.swift 13 kick-cli timelines, line for line at printed precision; Swift’s physics tests mirrored
Kick solver Shot/Kick.swift Launch speed, elevation and spin in every timeline; aim within 0.04 m over 108 combinations
Goalkeepers Players/Keeper.swift, Players/Skeleton.swift The 3,600-penalty save table, identical; Swift’s keeper tests mirrored
Random numbers Math/Random.swift Integer arithmetic in BigInt, so the bits match; keeper guesses and reads in the fixtures depend on them
Fixed steps and replays Shot/Shot.swift Uneven frames and a 0.45× replay must end identically, in both suites
Swipe reading Shot/Swipe.swift Swift’s swipe tests with the same inputs and tolerances; no Swift output compared
Scoring, modes and the match Game/*.swift Swift’s game tests mirrored; no Swift output compared
Cameras, rendering, HUD, session Render/*.swift and the app’s play screens A whole game against a fake DOM and a recording 2D context; never compared with the app’s frames

For the next port of numeric code, whatever the two languages:

  • Port file by file, keep the source’s units and names, and label each section with the file it came from.
  • Keep the grouping of every expression and the order of every step; (a × b) × c and a × (b × c) can differ in the last bit.
  • Use a fixed timestep with an accumulator, and copy its comparison exactly, slack included.
  • Implement one seeded integer generator on both sides, give each event its own seed, and draw cosmetic randomness from a separate generator.
  • List every library function the simulation calls, because exp, sin, atan2, log and pow can differ by an ulp between platforms.
  • Before comparing printed output, check how each language rounds halves, prints negative zero and formats decimals.
  • Have the source engine write the fixtures with a tool, and avoid round inputs that put an output on a decimal tie.
  • Nudge library results and rerun the fixtures to learn what they can’t see.
  • Mirror the source’s unit tests with the same inputs and tolerances, and run both suites in CI on every change.
  • Measure the bundle as it arrives, compressed, and check the compression level.

  1. Ecma International, “ECMA-262, 16th edition (June 2025): Number.prototype.toFixed”, https://262.ecma-international.org/16.0/#sec-number.prototype.tofixed ↩

  2. Glenn Fiedler, “Fix Your Timestep!” (10 June 2004), https://gafferongames.com/post/fix_your_timestep/ ↩

  3. Sebastiano Vigna, “splitmix64.c” (2015), https://prng.di.unimi.it/splitmix64.c ↩

  4. Ecma International, “ECMA-262, 16th edition (June 2025): Number::exponentiate”, https://262.ecma-international.org/16.0/#sec-numeric-types-number-exponentiate ↩

  5. Swift project, “SIMDVector.swift”, Swift 6.2 standard library (September 2025), https://github.com/swiftlang/swift/blob/swift-6.2-RELEASE/stdlib/public/core/SIMDVector.swift ↩

  6. Swift project, “FloatingPoint.swift”, Swift 6.2 standard library (September 2025), https://github.com/swiftlang/swift/blob/swift-6.2-RELEASE/stdlib/public/core/FloatingPoint.swift ↩↩

  7. Ecma International, “ECMA-262, 16th edition (June 2025): Math.round”, https://262.ecma-international.org/16.0/#sec-math.round ↩

  8. Ecma International, “ECMA-262, 16th edition (June 2025): The Number Type”, https://262.ecma-international.org/16.0/#sec-ecmascript-language-types-number-type ↩

  9. Ecma International, “ECMA-262, 16th edition (June 2025): Math.sqrt”, https://262.ecma-international.org/16.0/#sec-math.sqrt ↩

  10. Ecma International, “ECMA-262, 16th edition (June 2025): Function Properties of the Math Object”, https://262.ecma-international.org/16.0/#sec-function-properties-of-the-math-object ↩

  11. Swift Evolution, “SE-0246: Generic Math(s) Functions”, https://github.com/swiftlang/swift-evolution/blob/main/proposals/0246-mathable.md ↩

  12. Glenn Fiedler, “Floating Point Determinism” (24 February 2010), https://gafferongames.com/post/floating_point_determinism/ ↩

  13. V8 project, “src/base/ieee754.cc”, V8 14.1.146 (September 2025), https://github.com/v8/v8/blob/14.1.146/src/base/ieee754.cc ↩

  14. V8 project, “gni/v8.gni” (v8_use_libm_trig_functions), V8 14.1.146 (September 2025), https://github.com/v8/v8/blob/14.1.146/gni/v8.gni ↩

  15. V8 project, “src/numbers/ieee754.cc” (use_std_math_pow), V8 14.1.146 (September 2025), https://github.com/v8/v8/blob/14.1.146/src/numbers/ieee754.cc ↩

Frequently asked questions

How do you port Swift code to JavaScript?

Port it file by file in the same units, keep the grouping of every expression and the same fixed timestep, and reimplement the random number generator exactly. Then test the port against output captured from the Swift code, as we did for Football Kick’s engine.

Do Swift Double and JavaScript Number give the same results?

Both are IEEE 754 double-precision numbers, so addition, subtraction, multiplication, division and square roots give identical results for identical inputs. Library functions such as exp, sin and atan2 can differ in the last bit, because each platform uses its own implementation.

Is Math.sin the same in every browser?

Not necessarily. The ECMAScript specification leaves Math.sin, Math.exp, Math.atan2 and similar functions implementation-approximated, so engines may round them differently. On one Mac, Node’s Math.sin and the system C library disagreed on 3.9% of a million inputs, by one ulp each time.

What is a parity test?

A test that runs the same inputs through two implementations and checks that the outputs match. Football Kick’s browser port is checked against kick timelines and a 3,600-penalty save table written by the Swift engine.

Why use a fixed timestep in a game?

With a fixed timestep the simulation advances in equal steps whatever the frame rate, so the same inputs always produce the same result. Replays, slow motion and tests depend on that.

How do you get the same random numbers in JavaScript and Swift?

Use your own seeded generator on both sides, because Math.random can’t be seeded. Football Kick uses SplitMix64, which needs 64-bit integer arithmetic, so the JavaScript port runs it on BigInt.

Work with us

Building something like this?

9io is a small team of senior engineers with a fractional CTO. We work by the hour, and plans start at $5,000. Send us a note about your product. The reply comes from the person who'd do the work.