
The Collapse is an interactive story game where players navigate a world on the brink of an apocalyptic event, making split-second decisions that shape what happens next.
It was built for iOS, Android, and Web using Expo.
We're porting it to Amazon Fire TV using Vega OS.
To get there, we also installed the Amazon Devices Builder Tools, a Model Context Protocol (MCP) server that gives AI coding agents Vega-specific tooling, documentation, debugging utilities, and project context. We leaned on it from the very first command, and you'll see it surface throughout the article.
The game is a natural fit for the TV experience, designed to be played with family and friends gathered around a screen, making decisions together in real time. Vega OS for Fire TV makes it easy to bring your React Native app to the living room.
Our starting point is a standard Expo application.
(yes, future readers, React Native was still below 1.0 at the time of writing)
the-collapse/ // a standard expo app ├── app/ │ ├── _layout.tsx │ ├── index.tsx │ └── game/ │ ├── intro.tsx │ ├── chapter-[id].tsx │ └── ending.tsx ├── components/ ├── assets/ ├── lib/ ├── app.json ├── package.json ├── tsconfig.json └── index.js
So porting this to another React Native platform should be straightforward… right?
Well, Houston, we have a problem.
The Vega SDK is built on a fork of React Native, pinned to RN 0.72 and React 18.2, while our app runs React Native 0.81.5. That leaves us with a real architectural decision: do we downgrade the entire application to match the Vega SDK?
The answer is no, and this is the first place the MCP earned its keep. When we asked our agent (Claude in our case) to build the initial scaffold, we hit an unknown command 'build-vega' error.
The obvious instinct was to assume our toolchain was broken.
A quick query to the MCP told us a different story:
It turns out build-vega isn't a built-in React Native command. It's contributed by @amazon-devices/react-native-kepler, and the command itself was renamed from build-kepler to build-vega in SDK 0.22+.
The folks at Econify documented a clever approach for extending Expo Prebuild to support Vega directly. (Prebuild is the Expo command that generates your native ios/ and android/ folders from config, so the idea is to generate a vega/ target the same way)
It's smart, and it works, but we went in a different direction.
We went with Turborepo.
Turborepo is a monorepo build system that caches task outputs and runs them in parallel across packages, so you're not rebuilding the mobile app every time you touch a shared component.
It gave us a clean way to run two React Native versions side by side.
The end result looks something like this:
the-collapse/ ├── apps/ │ ├── mobile/ # Expo native platform (iOS, Android, Web) │ │ └── index.js │ └── tv/ # Vega native platform (Fire TV) │ └── index.js ├── packages/ │ ├── app/ # @collapse/app, the entire JS application │ │ └── src/ │ │ ├── App.tsx │ │ ├── screens/ │ │ │ ├── StorySelect.tsx # mobile + web │ │ │ └── StorySelect.kepler.tsx # Fire TV variant │ └── config/ # shared tsconfig, eslint, prettier └── turbo.json
Both mobile and TV platforms mount the same <App />. Where the TV experience needs to diverge, we add a platform-specific .kepler.tsx file alongside the default implementation:
StorySelect.tsx // mobile + web StorySelect.kepler.tsx // Fire TV implementation
A minimal Metro setup for this looks like:
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); module.exports = mergeConfig(getDefaultConfig(__dirname), { resolver: { // Register Vega's custom platform platforms: ['kepler', 'ios', 'android', 'native', 'web'], // Resolve *.kepler.tsx before falling back to *.tsx sourceExts: [ 'kepler.tsx', 'kepler.ts', 'tsx', 'ts', 'jsx', 'js', 'json', ], }, });
That gives us clean platform separation without scattering Platform.isTV checks throughout the codebase. StorySelect.tsx can be the layout for iOS, Android, and web, while StorySelect.kepler.tsx can be the layout for Fire TV.
This is the part of the blog post where we pretend the architecture was planned from the start.
The shared @collapse/app package has to run against two different React Native ecosystems simultaneously: modern Expo on React Native 0.81.5 and Vega's React Native 0.72 runtime.
To keep that boundary honest, each app runs its own TypeScript configuration and CI typecheck. If shared code accidentally introduces an unsupported API, the incompatible target fails immediately.
That turns out to be one of the biggest advantages of the setup.
The same shared package is continuously validated against two completely different runtime environments. When Expo SDK 55 is released, apps/mobile can upgrade immediately. apps/tv can remain pinned to Vega's supported stack. The shared package moves at the pace of the slowest platform.
Once you introduce that split, though, you start seeing duplication. The kind that makes you wonder if your agent is billing you per file. Your AI agent naturally produces .kepler.tsx files alongside their standard React Native equivalents, but when you diff them, most of the logic is identical.
Styling was the most visible source of divergence in our case.
We were using Nativewind across the codebase, and once we introduced .kepler.tsx files, it immediately became a constraint. Nativewind is based on a fairly complex toolchain, including css-interop, Babel transforms, and Reanimated integration, and that stack doesn't align cleanly with Vega's React Native baseline.
The issue wasn't "styles don't work." It was that the styling system depends on runtime and build-time assumptions that differ between the two environments, and once those assumptions break, you can't safely share the same styling layer.
This is not a requirement of .kepler.tsx or of the monorepo structure itself. If the app had been built with plain React Native StyleSheet, this entire class of problems would largely disappear.
The Vega SDK isn't just a forked React Native runtime. A number of the libraries you would normally rely on in a mobile Expo app are also forked and shipped under the @amazon-devices/ namespace.
| Mobile (Expo) | Vega |
|---|---|
| react-native-reanimated@4.1.1 | @amazon-devices/react-native-reanimated@2.0.0+3.5.4 |
| react-native-svg@15.x | @amazon-devices/react-native-svg@2.x |
| react-native-safe-area-context@5.x | @amazon-devices/react-native-safe-area-context@2.x |
| expo-audio | @amazon-devices/react-native-w3cmedia |
Some of these are effectively drop-in replacements.
At this point, there's a natural temptation to unify the imports via Metro aliases:
// apps/tv/metro.config.js extraNodeModules: { 'react-native-reanimated': path.join(amazonDevices, 'react-native-reanimated'), 'react-native-svg': path.join(amazonDevices, 'react-native-svg'), },
On paper, this looks perfect.
In shared code, one import path in Metro resolves to the correct implementation per platform.
And in some cases, it is the right solution, especially for libraries like react-native-svg or safe-area-context, where the API surface is stable, and the fork is genuinely drop-in compatible.
But it becomes dangerous the moment the packages diverge internally.
TypeScript still thinks you are using the mobile version of the library. Metro silently swaps it at build time. It's the build-tool equivalent of The Truman Show, where everything looks normal, and the entire sky is a lie.
That means your shared code can compile against Reanimated 4 APIs while Vega is actually running a 3.5-compatible implementation.
Mobile works. TV crashes. Nothing in the type system warns you.
Reanimated is the clearest example. The API shape looks familiar, but the underlying version, Babel plugin, and runtime behaviour differ in subtle but important ways. Audio goes even further as it isn't a fork of Expo's module at all, but a separate implementation based on the W3C media model.
In @collapse/app, even something as simple as a scene transition or voice playback touches multiple of these systems at once. Here's what the version gap looks like in practice for a scene fade overlay.
On mobile, using Reanimated 4.x:
import Animated, { useSharedValue, useAnimatedStyle, withTiming, } from 'react-native-reanimated'; export function useSceneTransitionOverlay() { const opacity = useSharedValue(1); const overlayStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); const fadeTo = (toValue: number, durationMs: number) => { opacity.value = withTiming(toValue, { duration: durationMs }); }; return { fadeTo, overlayStyle }; }
Vega ships with @amazon-devices/react-native-reanimated@~2.0.0+3.5.4.
Same import shape, different package, Reanimated 3.5 API surface.
So the abstraction failure isn't "Vega doesn't support animations." It's that the same hook with the same name is backed by two libraries, one major version apart, with a Babel plugin that has to be named differently per platform (@amazon-devices/react-native-reanimated/plugin on Vega), and a styling layer using Nativewind that quietly depends on Reanimated internals we couldn't guarantee were stable across that version gap.
The Vega implementation reads almost identically.
The divergence is entirely in the package name at the top of the file and the Babel config:
import Animated, { useSharedValue, useAnimatedStyle, withTiming, } from '@amazon-devices/react-native-reanimated'; export function useSceneTransitionOverlay() { const opacity = useSharedValue(1); const overlayStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); const fadeTo = (toValue: number, durationMs: number) => { opacity.value = withTiming(toValue, { duration: durationMs }); }; return { fadeTo, overlayStyle }; }
The same pattern shows up again in audio playback, but here the Amazon Devices Builder Tools did something more useful.
It pointed us at a module we didn't know existed. Like that scene in Ratatouille where Ego takes a bite of cheese and gets transported back to his childhood, except instead of childhood, we got transported to a W3C spec, which is arguably less moving.
When we first ported the audio player, we didn't know whether Vega exposed expo-audio, the Web Audio API, a different module entirely, or nothing at all.
So we asked the MCP to port our audio system for us:
The answer was @amazon-devices/react-native-w3cmedia, which exposes the W3C HTMLAudioElement model on Vega:
import { AudioPlayer } from '@amazon-devices/react-native-w3cmedia'; const player = new AudioPlayer(); await player.initialize(); player.addEventListener('ended', onEnd); player.autoplay = true; player.src = url;
What's not in that snippet is the manifest.
W3C audio on Vega is gated by a [wants] block, a VegaOS config tag that declares which system capabilities the app requests:
[wants] [[wants.service]] id = "com.amazon.audio.stream" [[wants.service]] id = "com.amazon.audio.control" [[wants.service]] id = "com.amazon.mediabuffer.service" [[wants.service]] id = "com.amazon.mediatransform.service"
Without those declarations, the OS denies playback silently.
No exception, no warning, just no sound.
If a tree falls in the forest and nobody hears it, that's a [wants] block you forgot to add.
The MCP surfaced the exact block in the first document it returned.
On mobile, the same behaviour runs through expo-audio. The result is the same: a line of voice narration plays when a scene loads. The lifecycle models underneath are entirely different.
So where does that leave us?
The Collapse runs on Fire TV.
We're as surprised as you are.
The same screens, the same story branches, the same @collapse/app package that runs on iOS and Android. What changed is the scaffolding around it.
Turborepo gave each platform its own dependency tree. Mobile stays on React Native 0.81.5. TV stays pinned to Vega's tested 0.72 baseline. The shared package sits between them and gets typechecked against both on every commit, which means an incompatible API surfaces at CI time, not at "why is the Fire TV remote doing nothing" time.
The .kepler.tsx convention handled the rest. Where the platforms agree, one file. Where they don't (the styling stack, the audio lifecycle, the Reanimated version gap), a .kepler file that owns the divergence honestly instead of hiding it behind a Metro alias.
A forked runtime with a familiar-looking API is the exact terrain where AI assistants confidently produce code that compiles, runs, and is quietly wrong. Grounding the agent in SDK-versioned documentation isn't a nice-to-have on Vega. It's the difference between "ported in a weekend" and "debugging for a month."
Now, if you'll excuse us, we have a D-pad to argue with.
👉 Amazon Devices Builder Tools
This guide was sponsored by Amazon. If you want to see how to actually monetise your existing code on their platform, check out Amazon Devices Builder Tools.

