“Write once, run anywhere” is mostly true for Flutter. Our typing game, OxiType, is one Dart codebase that runs on macOS, Windows and Linux, and well over ninety percent of the code has no idea which system it is on. The game loop, the levels, the bosses, the scoring: all shared.
The last few percent is where the time went. Nothing in this list is exotic, and none of it showed up until we built and ran on the real system. If you are about to take a Flutter app to desktop, this is the list we wish we had.
Video: the official plugin doesn’t cover every desktop
The main menu plays a short looping video behind the buttons. On macOS it worked on day one with the standard video plugin from the Flutter team.
On Windows there was no video. The reason is simple once you look: that plugin supports Android, iOS, macOS and the web. It has no Windows or Linux implementation, so on those systems the player never gets ready.
We didn’t want to throw away a plugin that works well where it works, so the menu now picks a player per platform. Windows uses a community player built on a cross-platform media engine; everything else keeps the official one. The menu code asks one question (“am I on Windows?”), and the rest of the screen does not care which player it got.
Then came the part worth remembering. The new player needs a one-time setup call when the app starts. We added it at the top of the app, for everyone, and the macOS build broke. The setup belongs only to the platform that uses the player:
if (Platform.isWindows) {
MediaKit.ensureInitialized();
}
Rule we follow now: any plugin added for one platform gets its setup behind a platform check, in the same commit. A “harmless” global init is how a Windows fix becomes a macOS bug.
Windows: the store tells you who you are
Flutter can build a Windows package for the Microsoft Store, and the build itself went fine. The Store did not agree with what was inside it.
Every Windows package has an identity name and a publisher ID. Like most mobile developers, we filled the identity in with our usual reverse-domain style. The Store doesn’t accept an identity you invent. When you reserve the app name, it gives you the exact identity and publisher strings, and the package must match them character for character.
The fix was two lines in the packaging config. Finding out which two lines took longer. If you ship to the Microsoft Store, copy both values from the Store’s product page before your first build, and keep the package version in step with the app version. We bump both together on every release, so they never drift apart.
macOS: a framework with the wrong shape
macOS frameworks are folders with a strict layout. The real files live in a versioned subfolder, and the top level holds links that point into it. Most of the time this is invisible.
After a dependency update, one framework pulled in by a plugin arrived in the app with some of those top-level links missing. The app ran fine on our Macs. App Store validation flagged it as a malformed bundle.
We fixed it with a small build step that runs after the frameworks are embedded: if the expected links are missing, it creates them. It is a dull fix, and it holds. The lesson is not about that one framework. “It runs on my Mac” says nothing about the App Store. Run the store’s validation on every release build before you plan the release date, not after.
Linux: when the new renderer lags
Flutter has two rendering engines. Skia is the old one. Impeller is the new one, built to remove the stutter that shader compilation used to cause, and it’s now the default on more and more platforms.
On Linux, our game lagged badly. The same build on macOS was smooth, so this was not a slow game loop. The cause was the glow. The ships, the eighteen bosses and the buttons all carry a neon glow drawn with a blur filter, many times per frame. On Linux, Impeller’s OpenGL backend stalled on exactly that kind of drawing.
The fix is one line in the Linux runner that turns Impeller off for that platform and uses Skia instead. The game was smooth again in version 1.0.13. The other platforms keep their default renderer.
Two notes if you hit the same thing:
- Test the effect, not the app. A plain list screen will not show this. Heavy blur, many layers and lots of redraws will. Build a stress scene of your worst effect and run it on each platform.
- Write down why you switched. We left a comment next to that line naming the effect that caused it. When a future Flutter release fixes Impeller on Linux, we want to know what to re-test before we flip it back.
Windows are not phones
The last group is less about bugs and more about habits from mobile.
A phone gives you a screen of a known shape. A desktop gives you a window that can be any size, on a monitor of any size. Our game is designed for a 4:3 playing field, so at start we size the window from the real screen: two thirds of the screen height, width from the aspect ratio, a floor so the game stays playable on small laptops, and a ceiling so the window does not run under the taskbar or the dock.
Desktop players also expect things phones never asked for: a keyboard path through every menu (arrow keys, Enter, Escape), hotkeys, and a window that opens focused so the first key press is not lost. For a typing game that last one is not polish. If the window isn’t focused, the game can’t hear the keyboard.
Was it worth it?
Yes. We shipped one game to three desktop systems with one small team, and the platform code is a handful of short, well-marked spots. Each fix above is a few lines. Finding them is the real cost, and the only way we found any of them was to run the release build on the real system, early and often.
If you are choosing a stack for a cross-platform app, we explained our reasons for Flutter in Go, Flutter and Rust. The short version still holds: one codebase, honest about where it has to split.