couch-kit
    Preparing search index...

    couch-kit

    ๐ŸŽฎ Couch Kit

    Turn an Android TV / Fire TV into a local party-game console and use phones as web controllers.

    CI Release License TypeScript Coverage

    @couch-kit/host @couch-kit/client @couch-kit/core @couch-kit/cli @couch-kit/devtools


    • 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) and 8082 (WebSocket) reachable on the LAN (configurable).
    • Native deps: @couch-kit/host requires 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-kit setup (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 staticDir config option to point to a writable filesystem path where you've extracted the www/ assets at runtime. See the Buzz starter for a working example with useExtractAssets().

    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>
    );
    }
    • 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 by createGameReducer -- 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, 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 --link flag. Keeping the default file: 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:

    1. Run bun run build in the library repo.
    2. Run yalc push in the modified package folder (e.g., packages/host).
    3. Your game app should hot-reload automatically.

    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.

    1. Every PR must include a changeset (bun changeset to create one)
    2. 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)
    3. Consumer app repos (domino, buzz, card-game-engine) use Renovate to pick up new @couch-kit/* releases and open update PRs automatically

    Each 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-lockfile to fail in CI.

    • Phone canโ€™t open the controller page: confirm TV and phone are on the same Wiโ€‘Fi; verify serverUrl is 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" to useGameClient().
    • Connection is flaky: enable debug in 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.
    • 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.
    • The host rejects internal action injection, rate-limits actions (60/sec), ignores actions from clients that haven't JOINed, and discards inbound messages larger than 256 KiB (configurable via maxMessageBytes) to bound memory usage.