Turn an Android TV / Fire TV into a local party-game console and use phones as web controllers.
graph LR
subgraph TV["๐บ Android TV"]
HTTP["HTTP :8080"]
WS["WebSocket :8082"]
end
subgraph PHONES["๐ฑ Phones"]
P1["Player 1"]
P2["Player 2"]
end
HTTP -- "serves controller page" --> P1 & P2
P1 & P2 -- "actions โก" --> WS
WS -- "โฌ
state updates" --> P1 & P2
sequenceDiagram
participant P as ๐ฑ Phone
participant TV as ๐บ TV
P->>TV: GET controller page (HTTP)
TV-->>P: Web app
P->>TV: JOIN { name, secret }
TV-->>P: WELCOME { playerId, state }
loop Game Loop
P->>TV: ACTION { type, payload }
TV-->>P: STATE_UPDATE { state }
end
loop Heartbeat
TV-->>P: PING
P->>TV: PONG
end
8080 (HTTP) and 8082 (WebSocket) reachable on the LAN (configurable).@couch-kit/host requires Expo modules and React Native native modules; it is not a pure-JS package.Starter Project: The fastest way to get started is to clone the Buzz starter project โ a fully working buzzer game that demonstrates the complete
@couch-kitsetup (shared reducer, TV host, phone controller, build pipeline). Use it as a starting point for your own game.
| App | Description | Complexity |
|---|---|---|
| Buzz | Minimal buzzer party game | Starter |
| Domino | Dominos with hidden hands | Intermediate |
| Card Game Engine | JSON-driven card game engine (blackjack, poker, UNO) with expression evaluator and seeded PRNG | Advanced |
This guide assumes you are using the published @couch-kit/* packages from npm.
Prerequisite: You need an existing React Native TV application (Expo with TV config or
react-native-tvos). If you don't have one yet, clone the Buzz starter โ it's a complete working project you can build on.
Install the library packages into your TV app:
# For the TV App (Host)
bun add @couch-kit/host @couch-kit/core
# Install required peer dependencies
npx expo install expo-file-system expo-network
bun add react-native-nitro-modules
If you are setting up the Web Controller manually (instead of using the CLI in Step 4):
# For the Web Controller (Client)
bun add @couch-kit/client @couch-kit/core
Define your game state and actions in a shared file (e.g., shared/types.ts). This ensures both your TV Host and Web Controller agree on the rules.
import { IGameState, IAction } from "@couch-kit/core";
export interface GameState extends IGameState {
score: number;
}
// Only define your own game actions.
// System actions (HYDRATE, PLAYER_JOINED, PLAYER_LEFT, PLAYER_RECONNECTED,
// PLAYER_REMOVED) are handled automatically by createGameReducer.
export type GameAction = { type: "BUZZ" } | { type: "RESET" };
export const initialState: GameState = {
status: "lobby",
players: {}, // Managed automatically
score: 0,
};
// Your reducer only handles your own actions.
// Player tracking and state hydration are handled by the framework.
export const gameReducer = (
state: GameState,
action: GameAction,
): GameState => {
switch (action.type) {
case "BUZZ":
return { ...state, score: state.score + 1 };
case "RESET":
return { ...state, score: 0 };
default:
return state;
}
};
In your React Native TV app (using react-native-tvos or Expo with TV config):
import { GameHostProvider, useGameHost } from "@couch-kit/host";
import { gameReducer, initialState } from "./shared/types";
import { Text, View } from "react-native";
export default function App() {
return (
<GameHostProvider config={{ reducer: gameReducer, initialState }}>
<GameScreen />
</GameHostProvider>
);
}
Tip: On Android, APK-bundled assets live inside a zip archive and cannot be served directly. Use the
staticDirconfig option to point to a writable filesystem path where you've extracted thewww/assets at runtime. See the Buzz starter for a working example withuseExtractAssets().
function GameScreen() {
const { state, serverUrl, serverError } = useGameHost();
return (
<View>
{serverError && <Text>Server error: {String(serverError.message)}</Text>}
<Text>Open on phone: {serverUrl}</Text>
<Text>Score: {state.score}</Text>
</View>
);
}
Scaffold a web controller for players to run on their phones:
bunx couch-kit init web-controller
In web-controller/src/App.tsx:
import { useGameClient } from "@couch-kit/client";
import { gameReducer, initialState } from "../../shared/types";
export default function Controller() {
const { state, sendAction } = useGameClient({
reducer: gameReducer,
initialState,
});
return (
<button onClick={() => sendAction({ type: "BUZZ" })}>
BUZZ! (Score: {state.score})
</button>
);
}
__HYDRATE__, __PLAYER_JOINED__, __PLAYER_LEFT__, __PLAYER_RECONNECTED__, __PLAYER_REMOVED__) under the hood. These are handled automatically by createGameReducer -- you do not need to handle them in your reducer.playerId. Disconnected players are cleaned up after a timeout (default: 5 minutes).useGameClient() will try to connect WS to the laptop by default. In dev, pass url: "ws://TV_IP:8082".On the TV host:
<GameHostProvider
config={{
reducer: gameReducer,
initialState,
devMode: true,
devServerUrl: "http://192.168.1.50:5173",
}}
>
<GameScreen />
</GameHostProvider>
On the controller (served from the laptop), explicitly point WS to the TV:
useGameClient({
reducer: gameReducer,
initialState,
url: "ws://192.168.1.99:8082", // TV IP
});
If you want to contribute to couch-kit or test changes locally before they are published, follow these steps.
Clone the repository and install dependencies:
git clone https://github.com/faluciano/react-native-couch-kit.git
cd react-native-couch-kit
bun install
The packages (host, client, core, cli, devtools) are located in packages/*. You can build them all at once:
bun run build
Or individually:
bun run --filter @couch-kit/host build
Run all tests across all packages:
bun run test
Run linting and type checking:
bun run lint
bun run typecheck
Coverage is enforced in CI. The gate runs each package's tests with coverage,
counts only that package's own src/ (workspace imports of @couch-kit/* are
excluded), and fails if any package drops below its floor:
bun run coverage # enforce floors (used in CI)
bun run scripts/check-coverage.ts --report # print numbers without failing
Current own-src/ coverage (lines / functions):
| Package | Lines | Functions |
|---|---|---|
core |
~85% | ~91% |
client |
~36% | ~75% |
host |
~82% | ~78% |
cli |
~67% | ~73% |
devtools |
~99% | ~83% |
| overall | ~76% | ~82% |
Floors live in scripts/check-coverage.ts โ ratchet them up as coverage
improves; never lower them without a good reason.
The project uses Prettier for formatting (configured in .prettierrc) and ESLint for linting.
To test your local changes in a real React Native app, we recommend using yalc. It simulates a published package by copying build artifacts directly into your project, avoiding common Metro Bundler symlink issues.
First, publish local versions:
# In the root of couch-kit
bun global add yalc
bun run build
# Publish each package to local yalc registry
cd packages/core && yalc publish
cd ../client && yalc publish
cd ../host && yalc publish
Then, link them in your consumer app:
cd ../my-party-game
yalc add @couch-kit/core @couch-kit/client @couch-kit/host
bun install
Note: We do not use the
--linkflag. Keeping the defaultfile:protocol ensures files are copied inside your project root, which allows Metro Bundler to watch them correctly without extra configuration.
Iterating:
When you make changes to the library:
bun run build in the library repo.yalc push in the modified package folder (e.g., packages/host).Troubleshooting:
Duplicate React / Invalid Hook Call: Ensure your library packages treat react as a peerDependency and do not bundle it. yalc handles this correctly by default.
Changes not showing up? If you add new files or exports, Metro might get stuck. Stop the bundler and run:
bun start --reset-cache
| Package | Purpose |
|---|---|
@couch-kit/host |
Runs on the TV. Manages WebSocket server, serves static files, and holds the "True" state. |
@couch-kit/client |
Runs on the phone browser. Connects to the host and renders the controller UI. |
@couch-kit/core |
Shared TypeScript types and protocol definitions. |
@couch-kit/cli |
CLI tools to scaffold, bundle, and simulate web controllers |
@couch-kit/devtools |
Optional debug overlay component for web controllers (state inspector). |
This repo uses Changesets for versioning and publishing.
bun changeset to create one)main, the release workflow either:
@couch-kit/* releases and open update PRs automaticallyEach consumer repo has a renovate.json scoped to the @couch-kit/* packages (grouped into a single PR). Renovate regenerates the Bun lockfile, then each repo runs its own CI (typecheck + build) on the PR. Patch/minor/digest updates that pass CI are auto-merged by Renovate; major version bumps skip auto-merge and require manual review, so breaking changes surface as a held PR in each app.
Renovate (not Dependabot) is used because Dependabot does not regenerate Bun workspace lockfiles, which causes
bun install --frozen-lockfileto fail in CI.
serverUrl is not null.url: "ws://TV_IP:8082" to useGameClient().debug in host/client and watch logs; keep the TV from sleeping.JOIN requires a secret field โ a persistent session token stored in the client's localStorage. The library uses it internally for session recovery. The raw secret is never broadcast to other clients; only a derived public playerId is shared in game state.JOINed, and discards inbound messages larger than 256 KiB (configurable via maxMessageBytes) to bound memory usage.