@jahandco/game-sdk
v0.8.1
Published
Jah and Co Game Studio SDK — platform bridge, typed clients, lobby UI, and server runtime for titles
Readme
@jahandco/game-sdk
The title-facing SDK for building games on Jah and Co Game Studio. Provides a typed Bridge protocol for communicating with the platform, domain clients (identity, wallet, lobby, chat, multiplayer, datastore, assets, ai), built-in lobby/roster Web Component UI (<jc-lobby>, <jc-roster-overlay>), and an isolate server runtime for authoritative game logic.
The SDK is framework-agnostic: developers can build titles using any rendering approach or engine they prefer (Canvas 2D, WebGL, Phaser, Three.js, PixiJS, BabylonJS, React, plain DOM, etc.).
npm install @jahandco/game-sdkEntry points
| Import | Contents |
|---|---|
| @jahandco/game-sdk | Main entry point — MockBridge, RemoteBridge, connectPlatformBridge, HUD_DEPTH, domain clients (IdentityClient, LobbyClient, ChatClient, MultiplayerClient, WalletClient, DataStoreClient, AssetsClient, AIClient), LOBBY_ELEMENT_TAG/ROSTER_OVERLAY_ELEMENT_TAG/LOBBY_THEME_DEFAULTS, resolveSocialConfig, and everything re-exported from @jahandco/protocol (Bridge, BridgeContracts, BridgeEvents, TICKET_CURRENCY_NAME, TOKEN_CURRENCY_NAME, etc.) |
| @jahandco/game-sdk/bridge | connectPlatformBridge, MockBridge, RemoteBridge, and protocol types. |
| @jahandco/game-sdk/clients | IdentityClient, LobbyClient, ChatClient, MultiplayerClient, WalletClient, DataStoreClient, AssetsClient, AIClient — import individually if you only want the typed client classes. |
| @jahandco/game-sdk/lobby-ui | Registers the <jc-lobby> and <jc-roster-overlay> custom elements (import for side effects, plus exported element classes and theme defaults). |
| @jahandco/game-sdk/server | onMessage, publish, publishTo, Secrets — for a title's server-authoritative logic run inside session-isolates sandbox. |
Booting a title
import "@jahandco/game-sdk/lobby-ui";
import type { JcLobbyElement, JcRosterOverlayElement } from "@jahandco/game-sdk/lobby-ui";
import { connectPlatformBridge, MockBridge } from "@jahandco/game-sdk/bridge";
import { IdentityClient, WalletClient } from "@jahandco/game-sdk/clients";
const TITLE_ID = "your-title-slug";
async function boot(): Promise<void> {
// Connect to the platform cabinet iframe if present, otherwise fall back to MockBridge for local dev
let bridge;
try {
bridge = (await connectPlatformBridge(TITLE_ID)) ?? new MockBridge(TITLE_ID);
} catch (error) {
document.body.textContent = error instanceof Error ? error.message : String(error);
throw error;
}
const app = document.getElementById("app")!;
// For multiplayer titles: mount the built-in lobby UI
const lobby = document.createElement("jc-lobby") as JcLobbyElement;
lobby.bridge = bridge;
lobby.titleId = TITLE_ID;
app.appendChild(lobby);
// In-gameplay roster overlay (Esc to open)
const roster = document.createElement("jc-roster-overlay") as JcRosterOverlayElement;
roster.bridge = bridge;
app.appendChild(roster);
lobby.addEventListener(
"jc-lobby-ready",
() => {
lobby.remove();
void roster.start();
void startGame({ mount: app, bridge });
},
{ once: true },
);
await lobby.start();
}
async function startGame({ mount, bridge }: { mount: HTMLElement; bridge: any }) {
const identity = new IdentityClient(bridge);
const wallet = new WalletClient(bridge);
const who = await identity.whoami();
const balance = await wallet.getWallet();
console.log(`Ready! Player: ${who.displayName}, Balance: ${balance.tokenBalance} Tokens`);
// Render your game canvas / initialize your game loop here...
}
void boot();The Bridge and its domain clients
Every Bridge implements:
bridge.request(domain, action, payload): Promise<response>
bridge.on(event, handler): () => void // returns an unsubscribe function
bridge.destroy(): voidTyped clients wrap this request/response interface across six domains:
IdentityClient
import { IdentityClient } from "@jahandco/game-sdk/clients";
const identity = new IdentityClient(bridge);
const who = await identity.whoami(); // { userId, displayName }WalletClient
import { WalletClient } from "@jahandco/game-sdk/clients";
const wallet = new WalletClient(bridge);
await wallet.getWallet(); // { tokenBalance, ticketBalance } — platform-wide
await wallet.getEntryPrice(); // { amount, currency } — flat Tokens cost to play
await wallet.getPerks(titleId?); // { perks: Perk[] }
await wallet.purchasePerk(perkId); // { success, balance }
await wallet.claimDailyReward(); // { claimed, balance }Two platform currencies exist:
- Tokens (
TOKEN_CURRENCY_NAME): Spent on entry fees to play titles. The entry fee is charged server-side upon connecting, so title code does not need to handle unpaid participants. - Tickets (
TICKET_CURRENCY_NAME): Earned from gameplay rewards and spent on in-game perks. Listen forwallet.rewardsGrantedevents (bridge.on("wallet.rewardsGranted", handler)) for real-time "+N Tickets" feedback.
LobbyClient
import { LobbyClient } from "@jahandco/game-sdk/clients";
const lobby = new LobbyClient(bridge);
await lobby.list(titleId?); // { lobbies: LobbySummary[] } — open public lobbies
await lobby.create(options?); // { lobbyId, name, code?, maxPlayers, status, members: LobbyMember[] }
await lobby.join(lobbyIdOrOptions?); // { lobbyId, members: LobbyMember[], ... }
await lobby.leave();
await lobby.setReady(ready);
await lobby.kick(userId); // host-only
await lobby.start(); // host-only — "round begins" sync signal
lobby.onMemberJoined(handler); // (member: LobbyMember) => void
lobby.onMemberLeft(handler); // ({ userId }) => void
lobby.onMemberReadyChanged(handler); // ({ userId, ready }) => void
lobby.onHostChanged(handler); // ({ userId }) => voidChatClient
import { ChatClient } from "@jahandco/game-sdk/clients";
const chat = new ChatClient(bridge);
await chat.send(text);
chat.onMessage((msg) => console.log(`${msg.displayName}: ${msg.text}`));MultiplayerClient
import { MultiplayerClient } from "@jahandco/game-sdk/clients";
const multiplayer = new MultiplayerClient(bridge);
await multiplayer.join(sessionId?);
await multiplayer.leave();
await multiplayer.syncState(state); // state: unknown, your custom shape
multiplayer.onPlayerJoined(handler);
multiplayer.onPlayerLeft(handler);
multiplayer.onStateUpdate(handler);DataStoreClient
Per-project key/value storage. From a browser client, access is automatically scoped to the calling player's verified userId:
import { DataStoreClient } from "@jahandco/game-sdk/clients";
const datastore = new DataStoreClient(bridge);
await datastore.get(key); // { entry: DataStoreEntry | null }
await datastore.set(key, value, expectedVersion); // { version } — optimistic concurrency
await datastore.increment(key, amount); // { value, version } — atomic increment
await datastore.remove(key, expectedVersion); // { removed }
await datastore.listKeys({ prefix?, limit? }); // { keys, cursor }AssetsClient
Reference, load, and render assets uploaded via Developer Studio's Asset Vault (images, audio, video, binary/data files):
import { AssetsClient } from "@jahandco/game-sdk/clients";
const assets = new AssetsClient(bridge);
// 1. Resolve live URL
const url = await assets.getUrl(assetId);
// 2. Load Image (HTMLImageElement) for Canvas 2D or WebGL textures
const image = await assets.loadImage(assetId);
ctx.drawImage(image, 0, 0);
// 3. Load Audio (HTMLAudioElement) for music & sound effects
const sound = await assets.loadAudio(assetId);
sound.play();
// 4. Load Video (HTMLVideoElement) for cutscenes or background video
const video = await assets.loadVideo(assetId, { autoplay: true, loop: true, muted: true });
document.body.appendChild(video);
// 5. Load JSON data or 3D Models / binary buffers
const config = await assets.loadJson(assetId);
const gltfBuffer = await assets.loadArrayBuffer(assetId);
// 6. List all approved assets for this title
const list = await assets.list({ type: "image" });AIClient
Server-proxied generative AI text for in-game NPC dialogue/decisions. A title's client can never hold a raw model API key (it'd ship in the browser bundle), so this routes through session-service, which holds the one platform-wide Gemini key and returns a structured JSON response:
import { AIClient } from "@jahandco/game-sdk/clients";
const ai = new AIClient(bridge);
const result = await ai.direct({
intent: "npc_dialogue", // any label your title defines — used server-side to shape the prompt
speaker: { id: "npc_1", name: "Lord Sinclair", temperament: "diplomatic" },
roster: [
{ id: "npc_1", name: "Lord Sinclair" },
{ id: "npc_2", name: "Lady Vance", suspicionScore: 40 },
],
history: ["Lady Vance: I don't trust the quiet ones.", "..."],
context: { day: 3, missionSabotaged: true },
});
console.log(result.content); // in-character line
console.log(result.targetId); // present when you asked the model to pick one — validate it's in your own roster before acting on it
console.log(result.suspicionDelta); // advisory only, ~-15..15Rate-limited per connection server-side. Always have a local fallback for when the call fails or times out — treat it like any other network request, not a guaranteed response. MockBridge returns a canned placeholder line for local dev, since it can't hold the real key either.
Built-in Lobby UI & Review Gating
The platform provides <jc-lobby> and <jc-roster-overlay> Web Components via @jahandco/game-sdk/lobby-ui.
import "@jahandco/game-sdk/lobby-ui";
const lobby = document.createElement("jc-lobby");
lobby.bridge = bridge;
lobby.titleId = TITLE_ID;
lobby.chatEnabled = true;
document.getElementById("app")?.appendChild(lobby);
lobby.addEventListener("jc-lobby-ready", () => {
lobby.remove();
// start game logic
});
await lobby.start();Theming
Restyle the lobby UI via CSS custom properties on the <jc-lobby> host:
jc-lobby {
--jc-lobby-bg: #111318;
--jc-lobby-surface: #1a1d24;
--jc-lobby-surface-border: #2c313d;
--jc-lobby-text: #f0f2f5;
--jc-lobby-text-muted: #8b949e;
--jc-lobby-accent: #3b82f6;
--jc-lobby-radius-panel: 12px;
}Play Mode Review Gating
When publishing a build, automated checks verify that multiplayer titles (realtime_multiplayer, turn_based_multiplayer) include lobby code, while single-player titles do not.
Server-authoritative logic (@jahandco/game-sdk/server)
Authoritative game logic runs server-side inside session-isolates' sandbox (isolated-vm, 128MB limit). It interacts via onMessage and publish:
import { onMessage, publish } from "@jahandco/game-sdk/server";
onMessage((event, payload) => {
if (event === "playerAction") {
// Process authoritative simulation...
publish("stateSync", { /* ... */ });
}
});
publish("ready", {});Reporting Match Outcome
To report the final outcome of a session, publish a payload matching GameCompletedPayloadSchema (re-exported from @jahandco/protocol):
publish("multiplayer.gameCompleted", {
winnerUserId: "user_123",
placements: [{ userId: "user_123", place: 1 }],
participantUserIds: ["user_123", "user_456"],
});Secrets
Per-project, per-environment values configured in Developer Studio (Project → Secrets in the sidebar, or jc secrets) — an API key, a signing secret, a third-party account id, anything your server logic needs but shouldn't have hardcoded in dist/server/server.js. Resolved fresh and injected into the sandbox at session start; never baked into the bundle on disk and never reachable from a title's browser client (there's no Secrets on MockBridge/RemoteBridge — this only exists in @jahandco/game-sdk/server):
import { Secrets } from "@jahandco/game-sdk/server";
const apiKey = Secrets.get("STRIPE_SECRET_KEY"); // string | undefined
if (!Secrets.has("STRIPE_SECRET_KEY")) {
publish("error", { message: "STRIPE_SECRET_KEY not configured for this environment" });
}session-isolates runs with no outbound network access at all (the same reason AIClient above is server-proxied instead of calling Gemini directly), so a secret read this way can't be used to make an HTTP call from inside your game logic — it's for in-sandbox use: HMAC/webhook signature checks, feature-gating, anything that doesn't require reaching another host. Secrets.get returns undefined for a key that isn't set for the session's current environment (development vs. production) — there's no throwing variant, so always check before assuming a value is present.
Manage secrets with the CLI:
jc secrets set STRIPE_SECRET_KEY sk_live_... # development by default
jc secrets set STRIPE_SECRET_KEY sk_live_... --production
jc secrets list [--production]
jc secrets remove <secretId>Values are write-only once saved — Developer Studio and jc secrets list only ever show key names, never values back out.
Local development & MockBridge
When developing locally without the Game Studio platform:
connectPlatformBridgeresolvesundefinedin standalone dev servers.- Fall back to
new MockBridge(titleId)for fully offline, in-memory local testing with simulated identity, wallet, and echo chat.
Title Architecture & Manual Scaffolding Guide
While jc game init or npx @jahandco/create-game scaffolds titles automatically, developers can manually structure or customize their titles.
Ideal Project Structure (React + SSR)
my-title/
├── index.html # Client HTML template with <!--ssr-outlet-->
├── server.js # Main application & SSR server (Vite middleware in dev, SSR in prod)
├── public/
│ ├── style.css
│ └── assets/ # Static game assets (sprites, audio, models)
├── src/
│ ├── App.tsx # Universal React game component
│ ├── main.tsx # Universal app factory
│ ├── entry-client.tsx # Client hydration, Bridge & <jc-lobby> bootstrap
│ ├── entry-server.tsx # Server-side HTML render (renderToString)
│ └── game/
│ └── server.ts # Authoritative multiplayer game logic (anti-cheat, math)
├── vite.config.ts # Vite config with @vitejs/plugin-react
├── package.json # Build and dev scripts
├── tsconfig.json
└── jc.config.json # Developer Studio project link (schemaVersion 1)Unified Build Output (dist/)
Titles compile all outputs into a single parent dist/ directory with clean client and server separation:
dist/
├── client/ # Public client build (Vite)
│ ├── index.html
│ ├── assets/
│ │ ├── entry-client-*.js
│ │ └── ...
│ └── style.css
└── server/ # Server logic (private)
├── entry-server.js # Universal React SSR render bundle (Vite SSR)
└── server.js # Authoritative game logic bundle (bundled automatically by CLI)dist/client/(Public): Produced byvite build --outDir dist/client. Contains the browser bundle, styles, and assets. When uploaded, these files are served to players' web browsers in Game Studio.dist/server/server.js(Private): Produced by this title's own build (scripts/build-server.mjs, esbuild) fromsrc/game/server.ts— not by thejcCLI, which only reads it (jc upload) or watches it (jc dev). A single self-contained IIFE bundle forsession-isolates(raw V8 sandbox). Kept private from browser downloads.dist/server/entry-server.js(Private): Produced byvite build --outDir dist/server --ssr src/entry-server.tsx. Used byserver.jsfor SSR rendering.
Step-by-Step Manual Scaffolding
1. Configure package.json
{
"name": "my-title",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "node server.js",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server.tsx",
"build": "vite build --outDir dist/client && vite build --outDir dist/server --ssr src/entry-server.tsx",
"preview": "NODE_ENV=production node server.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@jahandco/game-sdk": "^0.6.0",
"@jahandco/protocol": "^0.3.0",
"express": "^4.21.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}2. Create src/game/server.ts (Authoritative Game Logic)
import { onMessage, publish } from "@jahandco/game-sdk/server";
let currentScore = 0;
onMessage((event, payload: any) => {
if (event === "player_action") {
currentScore += 1;
publish("state_sync", { score: currentScore, message: "Action accepted by server" });
} else if (event === "ping") {
publish("pong", { echo: payload, at: Date.now() });
}
});
publish("ready", { score: currentScore });3. Create server.js (SSR Application Server)
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isProduction = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5173;
const base = process.env.BASE || "/";
async function createServer() {
const app = express();
let vite;
if (!isProduction) {
const { createServer: createViteServer } = await import("vite");
vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
base,
});
app.use(vite.middlewares);
} else {
app.use(base, express.static(path.resolve(__dirname, "dist/client"), { index: false }));
}
app.use("*", async (req, res, next) => {
const url = req.originalUrl.replace(base, "");
try {
let template;
let render;
if (!isProduction) {
template = await fs.readFile(path.resolve(__dirname, "index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/entry-server.tsx")).render;
} else {
template = await fs.readFile(path.resolve(__dirname, "dist/client/index.html"), "utf-8");
render = (await import("./dist/server/entry-server.js")).render;
}
const rendered = await render(url);
const html = template.replace("<!--ssr-outlet-->", rendered.html ?? "");
res.status(200).set({ "Content-Type": "text/html" }).send(html);
} catch (e) {
if (vite) vite.ssrFixStacktrace(e);
next(e);
}
});
return { app };
}
createServer().then(({ app }) =>
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
}),
);4. Create index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Title</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>5. Link with Developer Studio (jc.config.json)
{
"schemaVersion": 1,
"projectId": "proj_123",
"projectName": "My Title",
"projectSlug": "my-title",
"template": "default",
"serverEntry": "src/game/server.ts"
}Run jc upload to build and upload your title to Developer Studio.
