We were having our daily standup when my boss dropped a suggestion that would define my next three months.
"We need to tap more into the US market," he said. "We should really build this on Roku."
We built LiveSpaces, a digital signage solution that turns screens into remotely controlled billboards. We already had a polished, modern Android TV app built with Kotlin and Jetpack Compose. It was reactive, declarative, and beautiful.
Then came the kicker. "How hard can it be?" I thought to myself as the meeting wrapped up. "Roku holds roughly 50% of the connected TV market in the United States. If we’re targeting the waiting room or the hotel lobby in the West, we can't ignore the purple box. I’ll probably just find a cross-platform library, wrap our existing logic, and ship it."
I was dead wrong.
First Contact: No IDE, Just a Zip File
I went to download the SDK, expecting an installer like Android Studio or Xcode. Instead, the documentation just said:
"To get started, download the Hello World sample app and unzip it..."
That was it. No heavy environment. Just a folder structure that looked like this:
- components/: The XML files that define the layout (SceneGraph).
- source/: The BrightScript code (the logic).
- images/: Assets and splash screens.
- manifest: The app configuration.
It felt raw. To write code, I couldn't use a dedicated Roku IDE because one doesn't really exist. I fired up VS Code and installed the BrightScript Extension.
The docs pitched it as a "suite of tools," and to be fair, the community extension for VS Code is a lifesaver: it gives you debugging, code formatting, and a telnet log. But coming from the rich, auto-completing embrace of Android Studio, I felt like I had been handed a text editor and a prayer
The Stack: A Primer for Web & Android Devs
Before diving into the bugs, you need to understand what "Native Roku Development" actually looks like. It relies on two pillars: BrightScript and SceneGraph.
If you come from the Web World, think of SceneGraph as the HTML DOM. It is a tree of nodes defined in XML. BrightScript is the JavaScript, it’s the scripting language used to manipulate those nodes. But unlike React or Vue, there is no Virtual DOM or data binding engine. You are manually selecting nodes and updating their properties, closer to the days of jQuery.
If you come from Android, think of SceneGraph as the old res/layout XML system, but strictly enforced. There is no Jetpack Compose here. BrightScript is your Java/Kotlin, but with syntax that looks like Visual Basic. It’s case-insensitive, uses End Sub and End If, and has no concept of generic types.
The Paradigm Shift: Reactive vs. The Node Tree
The hardest part of the migration was the shift in mental model. In Android, I live in a world of Coroutines, Flow, and StateFlow. If I want to update the UI, I update the state, and the UI reacts.
Roku uses SceneGraph. It’s a node-based architecture. But here is the catch: Threading is manual and strict.
1. Concurrency: Coroutines vs. Task Nodes
In Android, fetching data is a simple suspend function.
Android (Kotlin):
// Modern and clean
viewModelScope.launch {
val data = repository.fetchPlaylist()
_uiState.value = data
}
On Roku, you can't just run a background thread. You have to create a Task Node (literally an XML file representing a thread), spin it up, pass it data, and then have your main thread observe a field on that task to see when it changes.
Roku (BrightScript):
' 1. Create the task object
m.contentTask = CreateObject("roSGNode", "ContentTask")
' 2. Set the observer (callback)
m.contentTask.observeField("response", "onContentResponse")
' 3. Trigger the task
m.contentTask.control = "RUN"
2. The Missing Operators
I caught myself constantly trying to use Kotlin conveniences.
I’d type:
m.top.posterUri = item.thumbnail_url ?? "pkg:/images/placeholder.png"
BrightScript: "We don't do that here."
I had to write helper functions just to check if a string was valid without crashing the app.
The Trenches: 3 Bugs That Almost Broke Me
Developing for digital signage is different from developing a streaming app like Netflix. Netflix expects you to watch for 2 hours and turn it off. Digital signage runs 24/7. It must never sleep, never crash, and never complain.
Here are the specific technical hurdles I hit, and the "MacGyver" workarounds I used to fix them.
Bug #1: The "Double JSON" Parsing Error
The Issue: One of the data streams we consumed had a serialization inconsistency. Specifically, JSON data sometimes arrived as native JSON objects, and other times as Strings containing JSON.
Scenario A: API returns "{ 'id': 1 }" (String)
Scenario B: API returns { 'id': 1 } (Object)
The Roku Crash: Roku's ParseJson() is strict. If you pass it an object that is already parsed, or try to access properties on a string as if it were an object, the app crashes immediately.
The Fix: I implemented "Defensive Parsing" logic that checks the type at runtime.
' The "Double Parse" Workaround
if type(serverData.payload) = "roString"
' It is a string, so we must parse it manually
serverData.payload = ParseJson(serverData.payload)
else if type(serverData.payload) = "roAssociativeArray"
' It is already an object, do nothing
end if
Bug #2: The Battle Against the Screensaver
The Issue: Roku is aggressive about preventing screen burn-in. If no user input is detected for 10 minutes, the screensaver kicks in. For a digital signage app, this is a death sentence.
The Complication: While we advise our users to maximize their screensaver timeout settings, many Roku devices generally do not offer a "Never" option in the consumer menu. The OS will eventually force a sleep mode if no media is playing.
The Failed Attempt: I tried setting screensaver_mode=disabled in the manifest. While documented, this flag is often ignored by the OS or flagged by the Store Certification team as a policy violation.
The Solution: I found a legendary hack deep in the developer forums. The 1x1 Pixel Ghost Video.
Roku will not trigger the screensaver if a video is playing. So, I created a component that plays a video that is invisible to the human eye but visible to the OS.
<!-- MainScene.xml -->
<Video
id="keepAwakeVideo"
uri="pkg:/images/silent_1sec_black.mp4"
width="1"
height="1"
translation="[-10, -10]"
opacity="0.01"
loop="true" />
Logic: It’s 1 pixel wide, 99% transparent, positioned off-screen, and loops forever.
Result: The Roku thinks, "The user is watching a movie," and keeps the screen awake 24/7.
Bug #3: No WebViews Allowed
The Issue: Our Android app relies heavily on WebView components to render dynamic web dashboards, embedded presentation slides, and third-party widgets.
The Reality: Roku does not support WebViews. Period. There is no HTML rendering engine available to developers in the SDK. You cannot simply "embed" a website.
The Workaround: We had to implement a simple fallback system.
If the content type is "Web Page," the Roku app automatically generates a QR Code using an external API (api.qrserver.com).
The UI displays a message: "Web content not natively supported on Roku. Scan to view on mobile."
It wasn't the seamless experience we had on Android, but it saved the feature from being cut entirely.
UI Limitations: The Font Struggle
Polishing the UI made me miss Jetpack Compose dearly.
In Compose, if I want a text header to be 24sp, I just write fontSize = 24.sp.
On Roku, you technically can use custom fonts, but the implementation is incredibly verbose. According to the docs, you have to define a Font node, set the URI to a .ttf file, and assign it to the label.
<!-- The XML way -->
<Label>
<Font role="font" uri="pkg:/fonts/my_font.ttf" size="24" />
</Label>
But what if you just want to use the System font, but slightly bigger?
Roku provides presets like MediumBoldSystemFont or LargeSystemFont. But if Large is too small and ExtraLarge is too big, you are stuck.
To get our branding right without importing custom TTF files for every single variation, I ended up using a programmatic override in the BrightScript logic:
' The BrightScript way: Manually instantiating nodes
label = CreateObject("roSGNode", "Label")
font = CreateObject("roSGNode", "Font")
font.uri = "font:MediumBoldSystemFont" ' Start with a preset
font.size = 58 ' Override the size manually
label.font = font
It works, but it’s 5 lines of code for what takes 1 line in Compose.
The Verdict: Android vs. Roku
After three months of wrestling with the emulator and finally testing on a real TV, I created this mental map for any Android dev attempting this migration:
| Feature | Android TV (Kotlin) | Roku (BrightScript) |
|---|---|---|
| Threading | Coroutines (Easy) | Task Nodes (Manual, XML-based) |
| State | StateFlow / LiveData | observeField (Callback hell) |
| HTML | WebView (Full support) | Unsupported (QR Code fallback) |
| Fonts | .ttf / Dynamic Sizing | Verbose XML setup or Manual overrides |
| Memory | Robust Garbage Collection | Strict ref-counting (Watch for mem leaks!!) |
| Background | WorkManager | None (App freezes on exit) |
My Advice to My Past Self
If I could travel back in time back to November, I would tell myself:
"Don't fight the platform. It is not Android."
In the beginning, I tried to force Android architecture patterns (MVVM, Repositories) into BrightScript, and it caused nothing but friction. Once I accepted SceneGraph for what it was: a hierarchical, event-driven state machine, the code started to flow.
It’s like learning a language from a different era. It requires patience, but there is a strange satisfaction in mastering its strict rules. Plus, seeing our app running flawlessly on a $25 device in a busy hotel lobby? That makes the struggle worth it.

Top comments (2)
Since I have a Roku, I thought about making a small Roku app just for fun. However, you certainly don't make it sound fun!
Haha don't let me scare you off. For a simple streaming app or a hobby project, I think it’s actually kind of a fun challenge. My pain mostly came from trying to force complex Android architecture and 24/7 uptime requirements into it. If you stick to the standard templates, it’s much smoother and straightfoward. You should totally give it a shot!