Turn an Android TV / Fire TV into a local party-game console and use phones as web controllers.
- Local-first: TV runs HTTP (controller) + WebSocket (game) on your LAN.
- TV-as-server: Single source of truth lives on the TV.
- Shared reducer: One reducer shared between host + controller.
- Time sync + preloading: Helpers for timing-sensitive games and heavy assets.
- Session recovery: Players automatically get their state back after refreshing or reconnecting.
- Dev workflow: Iterate on the controller without constantly rebuilding the TV app.
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
- Devices: Android TV / Fire TV (host). Phones run any modern mobile browser (client).
- Network: TV + phones on the same LAN/Wi-Fi. This is not an internet relay.
- Ports:
8080(HTTP) and8082(WebSocket) reachable on the LAN (configurable). - Native deps:
@couch-kit/hostrequires Expo modules and React Native native modules; it is not a pure-JS package.
- Internet matchmaking / relay servers
- Anti-cheat, account systems, payments
- Hard security guarantees on untrusted networks
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.
Create a new project and install the library:
# Initialize a new repository
mkdir my-party-game
cd my-party-game
bun init
# 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-tcp-socket react-native-nitro-modulesIf 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/coreDefine 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-controllerIn 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>
);
}- System actions are automatic: The framework uses internal action types (
__HYDRATE__,__PLAYER_JOINED__,__PLAYER_LEFT__,__PLAYER_RECONNECTED__,__PLAYER_REMOVED__) under the hood. These are handled automatically bycreateGameReducer-- you do not need to handle them in your reducer. - State updates: The host broadcasts full state snapshots. The client applies them automatically via hydration.
- Session recovery is automatic: When a player refreshes or reconnects, the library restores their previous player data automatically. Player IDs are stable across reconnections — the same device always gets the same
playerId. Disconnected players are cleaned up after a timeout (default: 5 minutes). - Dev-mode WebSocket: if the controller is served from your laptop (Vite),
useGameClient()will try to connect WS to the laptop by default. In dev, passurl: "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 <this-repo>
cd couch-kit
bun installThe packages (host, client, core, cli) are located in packages/*. You can build them all at once:
bun run buildOr individually:
bun run --filter @couch-kit/host buildRun all tests across all packages:
bun run testRun linting and type checking:
bun run lint
bun run typecheckThe 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 publishThen, link them in your consumer app:
cd ../my-party-game
yalc add @couch-kit/core @couch-kit/client @couch-kit/host
bun installNote: 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:
- Run
bun run buildin the library repo. - Run
yalc pushin the modified package folder (e.g.,packages/host). - Your game app should hot-reload automatically.
Troubleshooting:
- Duplicate React / Invalid Hook Call: Ensure your library packages treat
reactas apeerDependencyand do not bundle it.yalchandles 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 |
This repo uses Changesets for versioning and publishing.
- Every PR must include a changeset (
bun changesetto create one) - On merge to
main, the release workflow either:- Creates a "Version Packages" PR if there are pending changesets
- Publishes to npm if the version PR was merged (no pending changesets)
- After publishing, a
repository_dispatchevent is sent to consumer app repos (domino, buzz, card-game-engine) which automatically creates a PR updating@couch-kit/*dependencies
When publishing a major version bump, the auto-created PRs require manual review. CI in each app repo (typecheck + build) catches any breakage. Breaking-change issues are also filed and assigned to Copilot for migration guidance.
- Phone can’t open the controller page: confirm TV and phone are on the same Wi‑Fi; verify
serverUrlis not null. - Phone opens page but actions do nothing: check that your reducer handles your custom action types and the host isn’t erroring.
- Dev mode WS fails: pass
url: "ws://TV_IP:8082"touseGameClient(). - Connection is flaky: enable
debugin host/client and watch logs; keep the TV from sleeping.
- The controller URL is reachable to anyone on the same LAN. Don’t run this on untrusted Wi‑Fi.
JOINrequires asecretfield — a persistent session token stored in the client'slocalStorage. The library uses it internally for session recovery. The raw secret is never broadcast to other clients; only a derived publicplayerIdis shared in game state.