diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md
index 00a5eed02..6489f59fc 100644
--- a/.changelog/NEXT.md
+++ b/.changelog/NEXT.md
@@ -12,6 +12,9 @@
## Image generation
- **[issue-3186] Agy CLI is now an opt-in image-generation backend.** Users can enable Antigravity in Image Gen settings, select an installed or custom model, and send text-to-image work through its `generate_image` tool with queue progress, cancellation, gallery metadata, cleaner options, and CoS tool registration. Each render runs in an isolated scratch directory and only a signature-verified directed image is imported; image-edit surfaces continue to use an edit-capable backend.
+## Game
+
+- **[issue-3177] Added a Game studio for assembling managed-app asset bundles.** Create a Game workspace, bind reusable Sprite records and Music tracks, compile an immutable versioned manifest with SHA-256 asset references, and request persisted AI feedback with any configured provider, model, and supported effort. The new `/game/:id` workspace is reachable from Create, Cmd+K, and voice navigation.
## Quota burn
diff --git a/client/src/App.jsx b/client/src/App.jsx
index cc2676366..8eea4209a 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -76,6 +76,7 @@ const CreativeDirector = lazyWithReload(() => import('./pages/CreativeDirector')
const CreativeDirectorDetail = lazyWithReload(() => import('./pages/CreativeDirectorDetail'));
const CreativeCommissions = lazyWithReload(() => import('./pages/CreativeCommissions'));
const CreativeCommissionDetail = lazyWithReload(() => import('./pages/CreativeCommissionDetail'));
+const Game = lazyWithReload(() => import('./pages/Game'));
const MusicVideo = lazyWithReload(() => import('./pages/MusicVideo'));
const Sprites = lazyWithReload(() => import('./pages/Sprites'));
const MoodBoards = lazyWithReload(() => import('./pages/MoodBoards'));
@@ -399,6 +400,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx
index 26dc8d87a..1c2ed452c 100644
--- a/client/src/components/Layout.jsx
+++ b/client/src/components/Layout.jsx
@@ -95,7 +95,8 @@ import {
Clapperboard,
PersonStanding,
Box,
- Boxes
+ Boxes,
+ Gamepad2
} from 'lucide-react';
/* global __APP_VERSION__ */
import { safeReadStorage, safeWriteStorage } from '../lib/safeStorage';
@@ -226,6 +227,7 @@ const navItems = [
{ to: '/creative-commission', label: 'Creative Commissions', icon: CalendarClock },
{ to: '/creative-director', label: 'Creative Director', icon: Clapperboard },
{ to: '/pipeline/editorial-checks', label: 'Editorial Checks', icon: ListChecks },
+ { to: '/game', label: 'Game', icon: Gamepad2 },
{ to: '/importer', label: 'Importer', icon: FileInput },
{ to: '/media', label: 'Media Gen', icon: Layers },
{ to: '/mood-boards', label: 'Mood Boards', icon: Palette },
diff --git a/client/src/components/games/GameBindings.jsx b/client/src/components/games/GameBindings.jsx
new file mode 100644
index 000000000..f32dbd07e
--- /dev/null
+++ b/client/src/components/games/GameBindings.jsx
@@ -0,0 +1,170 @@
+import { useMemo, useState } from 'react';
+import { Music2, PersonStanding, Plus, Unlink } from 'lucide-react';
+
+const selectClass = 'w-full min-h-[44px] rounded-lg border border-port-border bg-port-bg px-3 py-2 text-sm text-white';
+const actionClass = 'inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg bg-port-accent px-3 py-2 text-sm font-medium text-white disabled:opacity-50';
+
+function BindingSection({
+ title,
+ icon: Icon,
+ emptyText,
+ available,
+ value,
+ onValueChange,
+ onAdd,
+ addLabel,
+ disabled,
+ children,
+}) {
+ const selectId = `game-${title.toLowerCase().replace(/\s+/g, '-')}-picker`;
+ return (
+
+
+
+
{title}
+
+
+
+
+
+
+
+
+ {children || {emptyText}
}
+
+ );
+}
+
+export default function GameBindings({
+ game,
+ sprites,
+ tracks,
+ busy,
+ onBindSprite,
+ onUnbindSprite,
+ onBindMusic,
+ onUnbindMusic,
+}) {
+ const [spriteId, setSpriteId] = useState('');
+ const [trackId, setTrackId] = useState('');
+ const spriteMap = useMemo(() => new Map(sprites.map((sprite) => [sprite.id, sprite])), [sprites]);
+ const trackMap = useMemo(() => new Map(tracks.map((track) => [track.id, track])), [tracks]);
+ const boundSpriteIds = new Set(game.spriteBindings.map((binding) => binding.spriteId));
+ const boundTrackIds = new Set(game.musicBindings.map((binding) => binding.trackId));
+ const availableSprites = sprites
+ .filter((sprite) => !boundSpriteIds.has(sprite.id))
+ .map((sprite) => ({ id: sprite.id, label: `${sprite.name} · ${sprite.kind}` }));
+ const availableTracks = tracks
+ .filter((track) => !boundTrackIds.has(track.id))
+ .map((track) => ({ id: track.id, label: `${track.title}${track.audioFilename ? '' : ' · no audio yet'}` }));
+
+ const addSprite = async () => {
+ if (!spriteId) return;
+ if (await onBindSprite(spriteId)) setSpriteId('');
+ };
+ const addMusic = async () => {
+ if (!trackId) return;
+ if (await onBindMusic(trackId)) setTrackId('');
+ };
+
+ return (
+
+
+ {game.spriteBindings.length > 0 ? (
+
+ {game.spriteBindings.map((binding) => {
+ const sprite = spriteMap.get(binding.spriteId);
+ return (
+ -
+
+
{sprite?.name || binding.spriteId}
+
{sprite ? `${sprite.kind} · ${sprite.status}` : 'Record unavailable'}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+
+
+ {game.musicBindings.length > 0 ? (
+
+ {game.musicBindings.map((binding) => {
+ const track = trackMap.get(binding.trackId);
+ return (
+ -
+
+
{track?.title || binding.trackId}
+
{track?.audioFilename ? 'Audio ready' : 'Audio render required'}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+
+ );
+}
diff --git a/client/src/components/games/GameCompilePanel.jsx b/client/src/components/games/GameCompilePanel.jsx
new file mode 100644
index 000000000..2e33d97c9
--- /dev/null
+++ b/client/src/components/games/GameCompilePanel.jsx
@@ -0,0 +1,47 @@
+import { Boxes, RefreshCw } from 'lucide-react';
+import { formatDateShort } from '../../utils/formatters.js';
+
+export default function GameCompilePanel({ game, compiling, onCompile }) {
+ const current = game.compiledManifest;
+ return (
+
+
+
+
+
+
Game asset bundle
+
+
+ Compile immutable sprite-atlas and music references into a deterministic manifest.
+
+
+
+
+
+ {current ? (
+
+ - Version
- v{current.version}
+ - Sprites
- {current.spriteCount}
+ - Music
- {current.musicCount}
+ - Built
- {formatDateShort(current.builtAt)}
+
+
- Manifest
+ - {current.manifestPath}
+
+
+ ) : (
+
+ No bundle compiled yet. Bound sprites need a current runtime atlas and bound tracks need audio.
+
+ )}
+
+ );
+}
diff --git a/client/src/components/games/GameFeedback.jsx b/client/src/components/games/GameFeedback.jsx
new file mode 100644
index 000000000..80240bf6c
--- /dev/null
+++ b/client/src/components/games/GameFeedback.jsx
@@ -0,0 +1,106 @@
+import { useState } from 'react';
+import { MessageSquareText, Send } from 'lucide-react';
+import ProviderModelSelector from '../ProviderModelSelector.jsx';
+import EffortSelect from '../cos/EffortSelect.jsx';
+import useProviderModels from '../../hooks/useProviderModels.js';
+import { formatDateShort } from '../../utils/formatters.js';
+
+export default function GameFeedback({ history, submitting, onSubmit }) {
+ const [prompt, setPrompt] = useState('');
+ const [effort, setEffort] = useState('');
+ const {
+ providers,
+ selectedProviderId,
+ selectedModel,
+ availableModels,
+ setSelectedProviderId,
+ setSelectedModel,
+ loading,
+ } = useProviderModels({ silent: true });
+ const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
+ const promptId = 'game-feedback-prompt';
+
+ const submit = async (event) => {
+ event.preventDefault();
+ if (!prompt.trim() || !selectedProviderId) return;
+ const ok = await onSubmit({
+ providerId: selectedProviderId,
+ ...(selectedModel ? { model: selectedModel } : {}),
+ ...(effort ? { effort } : {}),
+ prompt: prompt.trim(),
+ });
+ if (ok) setPrompt('');
+ };
+
+ return (
+
+
+
+
AI feedback
+
+
+ Ask any configured provider to critique asset coverage and recommend the next improvements.
+
+
+
+
+ {history.length > 0 ? (
+
+
Feedback history
+ {[...history].reverse().map((entry) => (
+
+
+ {entry.providerId}
+ {entry.model ? · {entry.model} : null}
+ {entry.effort ? · {entry.effort} : null}
+ · {formatDateShort(entry.createdAt)}
+
+ {entry.prompt}
+ {entry.text}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/client/src/components/games/GameFeedback.test.jsx b/client/src/components/games/GameFeedback.test.jsx
new file mode 100644
index 000000000..047ed19f8
--- /dev/null
+++ b/client/src/components/games/GameFeedback.test.jsx
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock('../../hooks/useProviderModels.js', () => ({
+ default: () => ({
+ providers: [{
+ id: 'codex',
+ name: 'Codex',
+ command: 'codex',
+ enabled: true,
+ defaultModel: 'gpt-5.6-terra',
+ models: ['gpt-5.6-terra'],
+ }],
+ selectedProviderId: 'codex',
+ selectedModel: 'gpt-5.6-terra',
+ availableModels: ['gpt-5.6-terra'],
+ setSelectedProviderId: vi.fn(),
+ setSelectedModel: vi.fn(),
+ loading: false,
+ }),
+}));
+
+import GameFeedback from './GameFeedback.jsx';
+
+describe('GameFeedback', () => {
+ it('submits the selected provider, model, effort, and review prompt', async () => {
+ const onSubmit = vi.fn(async () => true);
+ render();
+
+ await userEvent.selectOptions(screen.getByLabelText('Thinking effort'), 'high');
+ await userEvent.type(screen.getByLabelText('Review request'), 'Find missing gameplay assets.');
+ await userEvent.click(screen.getByRole('button', { name: 'Request feedback' }));
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ providerId: 'codex',
+ model: 'gpt-5.6-terra',
+ effort: 'high',
+ prompt: 'Find missing gameplay assets.',
+ });
+ });
+
+ it('renders persisted feedback history', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Add a victory cue.')).toBeInTheDocument();
+ expect(screen.getByText('Review the plan.')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/pages/Game.jsx b/client/src/pages/Game.jsx
new file mode 100644
index 000000000..6a7f3e010
--- /dev/null
+++ b/client/src/pages/Game.jsx
@@ -0,0 +1,230 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ArrowLeft, Gamepad2, Plus } from 'lucide-react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+import toast from '../components/ui/Toast';
+import AppContextPicker from '../components/AppContextPicker.jsx';
+import GameBindings from '../components/games/GameBindings.jsx';
+import GameCompilePanel from '../components/games/GameCompilePanel.jsx';
+import GameFeedback from '../components/games/GameFeedback.jsx';
+import {
+ bindGameMusic,
+ bindGameSprite,
+ compileGameAssets,
+ createGame,
+ getApps,
+ getGame,
+ listGames,
+ listSpriteRecords,
+ listTracks,
+ requestGameFeedback,
+ unbindGameMusic,
+ unbindGameSprite,
+} from '../services/api.js';
+import { timeAgo } from '../utils/formatters.js';
+
+const silent = { silent: true };
+
+export default function Game() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const [games, setGames] = useState([]);
+ const [apps, setApps] = useState([]);
+ const [sprites, setSprites] = useState([]);
+ const [tracks, setTracks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState('');
+ const [name, setName] = useState('');
+ const [appId, setAppId] = useState('');
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ const result = await Promise.all([
+ listGames(silent),
+ getApps(silent),
+ listSpriteRecords(silent),
+ listTracks(silent),
+ ]).catch(() => null);
+ if (!result) {
+ toast.error('Failed to load the Game studio');
+ } else {
+ const [gameRows, appRows, spriteRows, trackRows] = result;
+ setGames(Array.isArray(gameRows) ? gameRows : []);
+ setApps((Array.isArray(appRows) ? appRows : []).filter((app) => !app.archived));
+ setSprites(Array.isArray(spriteRows) ? spriteRows : []);
+ setTracks(Array.isArray(trackRows) ? trackRows : []);
+ }
+ setLoading(false);
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const game = useMemo(() => games.find((entry) => entry.id === id) || null, [games, id]);
+ const app = apps.find((entry) => entry.id === game?.appId);
+ const replaceGame = (updated) => setGames((current) =>
+ current.some((entry) => entry.id === updated.id)
+ ? current.map((entry) => (entry.id === updated.id ? updated : entry))
+ : [updated, ...current]);
+
+ const create = async (event) => {
+ event.preventDefault();
+ if (!name.trim() || !appId) return;
+ setBusy('create');
+ const created = await createGame({ appId, name: name.trim() }, silent).catch(() => null);
+ setBusy('');
+ if (!created) { toast.error('Failed to create Game'); return; }
+ replaceGame(created);
+ navigate(`/game/${created.id}`);
+ };
+
+ const mutate = async (key, action, successMessage) => {
+ setBusy(key);
+ const updated = await action().catch(() => null);
+ setBusy('');
+ if (!updated) { toast.error('Game update failed'); return false; }
+ replaceGame(updated);
+ if (successMessage) toast.success(successMessage);
+ return true;
+ };
+
+ const compile = async () => {
+ setBusy('compile');
+ const result = await compileGameAssets(game.id, silent).catch(() => null);
+ const refreshed = result ? await getGame(game.id, silent).catch(() => null) : null;
+ setBusy('');
+ if (!result || !refreshed) { toast.error('Bundle compilation failed'); return; }
+ replaceGame(refreshed);
+ toast.success(result.created ? `Compiled bundle v${result.version}` : `Bundle v${result.version} is already current`);
+ };
+
+ const feedback = async (payload) => {
+ setBusy('feedback');
+ const result = await requestGameFeedback(game.id, payload, silent).catch(() => null);
+ setBusy('');
+ if (!result?.game) { toast.error('AI feedback request failed'); return false; }
+ replaceGame(result.game);
+ toast.success('Feedback added');
+ return true;
+ };
+
+ if (loading) {
+ return Loading Game studio…
;
+ }
+
+ if (id && !game) {
+ return (
+
+
Game not found
+
This Game record may have been deleted.
+
+ Back to Games
+
+
+ );
+ }
+
+ if (!game) {
+ return (
+
+
+
+
+
Game
+
Bind reusable art and music to a managed app.
+
+
+
+
+
+ {games.length ? (
+
+ ) : (
+
+ No Game workspaces yet.
+
+ )}
+
+ );
+ }
+
+ const bindingBusy = Boolean(busy && busy !== 'compile' && busy !== 'feedback');
+ return (
+
+
+
+
+ All Games
+
+
+
+
+
{game.name}
+
{app?.name || 'Managed app unavailable'}
+
+
+
+
+
mutate('bind-sprite', () => bindGameSprite(game.id, spriteId, silent), 'Sprite bound')}
+ onUnbindSprite={(spriteId) => mutate('unbind-sprite', () => unbindGameSprite(game.id, spriteId, silent), 'Sprite unbound')}
+ onBindMusic={(trackId) => mutate('bind-music', () => bindGameMusic(game.id, trackId, silent), 'Music bound')}
+ onUnbindMusic={(bindingId) => mutate('unbind-music', () => unbindGameMusic(game.id, bindingId, silent), 'Music unbound')}
+ />
+
+
+
+ );
+}
diff --git a/client/src/services/README.md b/client/src/services/README.md
index 2cc24c19a..10bc8b27c 100644
--- a/client/src/services/README.md
+++ b/client/src/services/README.md
@@ -95,6 +95,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiMediaJobs.js` | Media generation job tracking. |
| `apiCreativeDirector.js` | Creative Director (video production). |
| `apiCreativeCommission.js` | Creative Commissions (Autonomous Creation Engine — standing recurring briefs). |
+| `apiGames.js` | Game studio records, managed-app binding, reusable sprite/music bindings, deterministic asset-bundle compilation, and AI feedback history. |
| `apiMusicVideo.js` | Music Video projects + scene board + audio analysis. |
| `apiSprites.js` | Sprite Manager records, asset library, production-set import (#2895), reference workflow: create/generate/lock (#2896), directional walk and per-track generation/approval, animation-type definition CRUD (#3153), trim/postprocess, and per-run source-frame listing for the Loop Trimmer's re-derive (#2980). |
| `apiThreejsModels.js` | Procedural Three.js model workspaces: gallery-image generation, refinement, source export, and deletion. |
diff --git a/client/src/services/api.js b/client/src/services/api.js
index 2490b9ba5..73f35297f 100644
--- a/client/src/services/api.js
+++ b/client/src/services/api.js
@@ -70,6 +70,7 @@ export * from './apiPeerSync.js';
export * from './apiSyncReview.js';
export * from './apiCreativeDirector.js';
export * from './apiCreativeCommission.js';
+export * from './apiGames.js';
export * from './apiMusicVideo.js';
export * from './apiSprites.js';
export * from './apiMoodBoard.js';
diff --git a/client/src/services/apiGames.js b/client/src/services/apiGames.js
new file mode 100644
index 000000000..5e3e71cdc
--- /dev/null
+++ b/client/src/services/apiGames.js
@@ -0,0 +1,51 @@
+import { request } from './apiCore.js';
+
+export const listGames = (options = {}) => request('/games', options);
+export const getGame = (id, options = {}) => request(`/games/${encodeURIComponent(id)}`, options);
+
+export const createGame = (body, options = {}) => request('/games', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ ...options,
+});
+
+export const updateGame = (id, patch, options = {}) => request(`/games/${encodeURIComponent(id)}`, {
+ method: 'PATCH',
+ body: JSON.stringify(patch),
+ ...options,
+});
+
+export const deleteGame = (id, options = {}) => request(`/games/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ ...options,
+});
+
+export const bindGameSprite = (id, spriteId, options = {}) => request(
+ `/games/${encodeURIComponent(id)}/sprites`,
+ { method: 'POST', body: JSON.stringify({ spriteId }), ...options },
+);
+
+export const unbindGameSprite = (id, spriteId, options = {}) => request(
+ `/games/${encodeURIComponent(id)}/sprites/${encodeURIComponent(spriteId)}`,
+ { method: 'DELETE', ...options },
+);
+
+export const bindGameMusic = (id, trackId, options = {}) => request(
+ `/games/${encodeURIComponent(id)}/music`,
+ { method: 'POST', body: JSON.stringify({ trackId }), ...options },
+);
+
+export const unbindGameMusic = (id, bindingId, options = {}) => request(
+ `/games/${encodeURIComponent(id)}/music/${encodeURIComponent(bindingId)}`,
+ { method: 'DELETE', ...options },
+);
+
+export const compileGameAssets = (id, options = {}) => request(
+ `/games/${encodeURIComponent(id)}/compile`,
+ { method: 'POST', ...options },
+);
+
+export const requestGameFeedback = (id, body, options = {}) => request(
+ `/games/${encodeURIComponent(id)}/feedback`,
+ { method: 'POST', body: JSON.stringify(body), ...options },
+);
diff --git a/client/src/services/apiGames.test.js b/client/src/services/apiGames.test.js
new file mode 100644
index 000000000..733b9d7f8
--- /dev/null
+++ b/client/src/services/apiGames.test.js
@@ -0,0 +1,47 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('./apiCore.js', () => ({
+ request: vi.fn(),
+}));
+
+let request;
+let api;
+
+beforeEach(async () => {
+ vi.resetModules();
+ ({ request } = await import('./apiCore.js'));
+ api = await import('./apiGames.js');
+ request.mockReset();
+ request.mockResolvedValue({});
+});
+
+describe('apiGames', () => {
+ it('forwards silent options without clobbering a sprite bind request', async () => {
+ await api.bindGameSprite('game/1', 'sprite/1', { silent: true });
+ expect(request).toHaveBeenCalledWith('/games/game%2F1/sprites', {
+ method: 'POST',
+ body: JSON.stringify({ spriteId: 'sprite/1' }),
+ silent: true,
+ });
+ });
+
+ it('posts arbitrary provider/model/effort feedback', async () => {
+ const body = {
+ providerId: 'codex',
+ model: 'gpt-5.6-terra',
+ effort: 'high',
+ prompt: 'Review this bundle.',
+ };
+ await api.requestGameFeedback('game-1', body, { silent: true });
+ expect(request).toHaveBeenCalledWith('/games/game-1/feedback', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ silent: true,
+ });
+ });
+
+ it('uses a POST for deterministic compilation', async () => {
+ await api.compileGameAssets('game-1');
+ expect(request).toHaveBeenCalledWith('/games/game-1/compile', { method: 'POST' });
+ });
+});
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 8ce8da0ca..b9d8e8aa9 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -36,6 +36,7 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
- `universes` / `universe_runs` — Universe Builder records (canon bibles, categories, composite sheets, locks, influences) one row per universe with the full sanitized record in `data` JSONB and `name`/`schema_version`/`ephemeral`/`updated_at`/`deleted`/`deleted_at` mirrored into columns; render-run history one row per run (local-only, capped 200, never federated). Migrated from `data/universes/{id}/index.json` (collectionStore) in Phase 3 Create slice 1 (#1014). **NO `sync_sequence`** — universes federate via the EXISTING `dataSync` snapshot/push model (LWW on the body's `updatedAt`), so the storage swap is invisible to peers (no schema-version bump). The store bumps an in-process mutation epoch on every write that `dataSync` folds into its checksum fingerprint, since a DB edit no longer changes the `data/universes/` directory the fingerprint used to watch. **`universe_runs` is intentionally never federated** — a regenerable render cache under a 200-row *global* cap that two producers would mutually evict, while the durable universe record already syncs (ADR [tribe + universe-runs local](./decisions/2026-06-26-tribe-and-universe-runs-local.md), #1724). Adapter: `server/services/universeBuilder/db.js`, dispatched via `store.js`.
- `tribe_people` / `tribe_touchpoints` / `tribe_memory_links` — the Tribe relationship/CRM graph (people + their care cadence, contact touchpoints, and cross-links into brain `memories`). **Intentionally machine-local — never federated** (ADR [tribe + universe-runs local](./decisions/2026-06-26-tribe-and-universe-runs-local.md), #1724): it is relationship-graph data, mirroring the deliberate "memory_links are instance-local" boundary in `memorySync.js` (memory *nodes* federate, the link graph does not), and is coupled to machine-local domains — `tribe_memory_links` extends the non-federated `memory_links` layer and `tribe_touchpoints` carry per-machine calendar-account refs. NO `sync_sequence`, no peer-sync record kind, no `dataSync` category. Adapter: `server/services/tribe.js`.
- `creative_commissions` — Creative Commissions (Autonomous Creation Engine Phase 1, #2657): standing recurring creative briefs that fire on a cron cadence and drive the Creative Director directive pipeline unattended. One row per commission, the full sanitized record (brief / schedule / generation / feedback / capped `runs[]`) in `data` JSONB with `name`/`enabled`/`created_at`/`updated_at` mirrored into columns for the scheduler's "arm every enabled commission" query. **Intentionally machine-local — never federated** (a synced schedule would double-run on every peer, same rationale as `seriesAutopilotScheduler`): NO `sync_sequence` and NO `deleted`/`deleted_at` tombstone — deletes are hard deletes, mirroring `tribe`. Phase 2 will split the machine-local *schedule* from a federatable *brief/feedback* record before adding any sync hook. Adapter: `server/services/creativeCommissions/db.js`, selected pg-vs-file by `server/services/creativeCommissions/store.js` (file backend is the `NODE_ENV=test`/`MEMORY_BACKEND=file` escape hatch only).
+- `games` — Game studio workspaces (#3177): one row per managed-app asset plan, with the full reusable sprite/music binding set, current compiled-manifest pointer, compile history, and user-requested AI feedback history in `data` JSONB; `app_id`/`name`/`updated_at` are mirrored for list and relationship queries. The record is **machine-local** because managed-app registration, sprite atlases, and music-library bytes are machine-local; there is no peer-sync cursor or tombstone, and deletes are hard deletes. Compiled manifests are immutable, SHA-256-addressed artifacts under `data/games/{id}/manifests/`; their pointers and hashes live in the DB record. Adapter: `server/services/games/db.js`, selected pg-vs-collectionStore by `server/services/games/store.js` (collectionStore is test/unsupported file escape hatch only).
- `threejs_models` — generated procedural 3D-model workspaces: one row per model with gallery-image lineage, provider/model attribution, generation/refinement status, and the validated declarative scene spec in `data` JSONB. The referenced image bytes remain under `data/images/`; deterministic Three.js source is derived from the stored spec rather than persisted as a second mutable artifact. The table is local-only in this first slice, with soft-delete columns retained so federation can be added without a record-shape migration.
**Postgres-First target.** Pipeline series/issues, Story Builder sessions, and searchable media metadata are still `db-primary` targets — they currently live in `data/` JSON but carry relationships and status that belong in the DB. The schema for the Create domains is designed in [`docs/plans/2026-06-07-create-relational-schema-design.md`](./plans/2026-06-07-create-relational-schema-design.md) (#999), with implementation tracked as #1014–#1018. (Creative Director project/scene/run state moved to Postgres in Phase 3 / #997; catalog user-defined types moved in Phase 4 lead-in / #1001; **universes moved in Phase 3 Create slice 1 / #1014** — see the `universes` entry above. Pipeline series/issues #1015, Story Builder #1016, Writers Room #1017, and the catalog ref resolver #1018 are the remaining slices.)
@@ -70,6 +71,7 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
**Examples.**
- Generated images — `data/images/*` bytes + `.metadata.json` sidecars; indexed into the `media_assets` table (#1000) keyed `image:`. Sidecars remain authoritative; the DB row is a derived, queryable mirror. Adapter: `server/services/mediaAssetIndex/`.
- Generated videos — `data/videos/*`, `data/video-thumbnails/*` bytes, tracked in `data/video-history.json`; indexed into `media_assets` keyed `video:`. History file remains authoritative.
+- Game asset manifests — immutable `data/games/{id}/manifests/game-assets-v{N}.json` artifacts reference sprite atlas and music-library bytes by stable path + SHA-256; the `games` DB record owns the current pointer and history. Both halves are backed up: PostgreSQL by the required dump, manifests and referenced media by the rsync snapshot.
- Media collections — many-to-many links over assets/universes/series/catalog media pointers (`db-primary` link tables) pointing at `asset-file-db-indexed` bytes. **Still `data/media-collections/*` JSON today** — a follow-up slice of #1000.
**Media asset index (`media_assets`, #1000).** One row per generated image/video: `media_key` (`:[`) PK, `kind`/`ref`/`created_at` mirror columns for queries, the full metadata record in `data` JSONB. It is a **derived index** — the on-disk sidecars + `video-history.json` stay authoritative — reconciled from disk at boot (upsert every asset, prune rows whose file is gone) and kept warm by a generation-`completed` hook. Local-only (rebuilt from disk), so no sync cursor/tombstone. Adapter: `server/services/mediaAssetIndex/{logic,db,index}.js`.
diff --git a/scripts/migrations/212-games-table.js b/scripts/migrations/212-games-table.js
new file mode 100644
index 000000000..c226e15b7
--- /dev/null
+++ b/scripts/migrations/212-games-table.js
@@ -0,0 +1,16 @@
+/**
+ * Registration stub for the games table and Game collection (#3177).
+ *
+ * The PostgreSQL DDL is idempotent and lives in ensureSchema(), which runs
+ * after the pool is available; the file migration runner executes before that
+ * point. Fresh installs receive the matching DDL from server/scripts/init-db.sql.
+ * The table is additive, so no data backfill is required. Tests and the
+ * unsupported MEMORY_BACKEND=file escape hatch mint the collectionStore v1
+ * index lazily on first write.
+ */
+
+export default {
+ async up() {
+ console.log('🎮 games: table created idempotently by ensureSchema at boot; nothing to do in the file runner (#3177)');
+ },
+};
diff --git a/server/index.js b/server/index.js
index 6e160f425..c244298af 100644
--- a/server/index.js
+++ b/server/index.js
@@ -109,6 +109,7 @@ import videoTimelineRoutes from './routes/videoTimeline.js';
import mediaJobsRoutes from './routes/mediaJobs.js';
import creativeDirectorRoutes from './routes/creativeDirector.js';
import creativeCommissionRoutes from './routes/creativeCommissions.js';
+import gamesRoutes from './routes/games.js';
import musicVideoRoutes from './routes/musicVideo.js';
import spriteRoutes from './routes/sprites.js';
import moodBoardRoutes from './routes/moodBoard.js';
@@ -336,6 +337,7 @@ app.use('/api/video-timeline', videoTimelineRoutes);
app.use('/api/media-jobs', mediaJobsRoutes);
app.use('/api/creative-director', creativeDirectorRoutes);
app.use('/api/creative-commission', creativeCommissionRoutes);
+app.use('/api/games', gamesRoutes);
app.use('/api/music-video', musicVideoRoutes);
app.use('/api/sprites', spriteRoutes);
app.use('/api/mood-boards', moodBoardRoutes);
diff --git a/server/lib/db/schema/audit.js b/server/lib/db/schema/audit.js
index 5dd1881e4..aed157eb2 100644
--- a/server/lib/db/schema/audit.js
+++ b/server/lib/db/schema/audit.js
@@ -89,7 +89,7 @@ export const auditedTables = [
'universes', 'universe_runs', 'pipeline_series', 'pipeline_issues',
'story_builder_sessions', 'writers_room_works', 'writers_room_folders',
'writers_room_draft_versions', 'catalog_ingredients', 'catalog_scraps',
- 'catalog_user_types', 'creative_director_projects', 'threejs_models', 'image_to_3d_models', 'sprite_records', 'mood_boards',
+ 'catalog_user_types', 'creative_director_projects', 'threejs_models', 'image_to_3d_models', 'sprite_records', 'games', 'mood_boards',
'lora_training_runs', 'authors', 'artists', 'albums', 'tracks', 'tribe_people', 'tribe_touchpoints',
];
diff --git a/server/lib/db/schema/media.js b/server/lib/db/schema/media.js
index d469679e0..2ecf43ba4 100644
--- a/server/lib/db/schema/media.js
+++ b/server/lib/db/schema/media.js
@@ -135,6 +135,22 @@ export const mediaDdl = [
)`,
`CREATE INDEX IF NOT EXISTS idx_sprite_records_live ON sprite_records (deleted) WHERE deleted = FALSE`,
+ // Game studio (#3177). One row per managed-app asset plan; reusable sprite
+ // and music bindings, compile pointers/history, and user-requested AI
+ // feedback history live in `data` JSONB. app_id/name/updated_at are mirrored
+ // for list and relationship queries. Compiled manifests stay on disk under
+ // data/games//manifests. Machine-local: app registry and referenced
+ // asset bytes are machine-local, so there is no peer-sync/tombstone shape.
+ `CREATE TABLE IF NOT EXISTS games (
+ id TEXT PRIMARY KEY,
+ app_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_games_app_updated ON games (app_id, updated_at DESC)`,
+
// Media asset index (Phase 3.2, issue #1000). One row per generated image
// or video; the bytes stay on disk (data/images, data/videos) and the
// sidecar/.json history files remain authoritative — this table is a
diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js
index cc594d79e..f3dae6011 100644
--- a/server/lib/navManifest.js
+++ b/server/lib/navManifest.js
@@ -21,6 +21,7 @@ export const NAV_COMMANDS = [
{ id: 'nav.media.collections', path: '/media/collections', label: 'Collections', section: 'Create', aliases: ['collections', 'media-collections', 'stacks', 'projects'], keywords: ['bucket', 'group', 'project', 'album', 'organize'] },
{ id: 'nav.create.creative-director', path: '/creative-director', label: 'Creative Director', section: 'Create', aliases: ['creative-director', 'creative', 'director', 'producer', 'orchestrator', 'long-form', 'episode'], keywords: ['story', 'episode', 'narrative', 'agent', 'auto-video', 'long-form', 'directive', 'production plan', 'plan board', 'orchestrator', 'studio', 'producer'] },
{ id: 'nav.create.creative-commission', path: '/creative-commission', label: 'Creative Commissions', section: 'Create', aliases: ['creative-commission', 'commissions', 'commission', 'creation-engine', 'autonomous-creation', 'standing-brief', 'recurring-brief'], keywords: ['schedule', 'recurring', 'nightly', 'brief', 'autonomous', 'creation engine', 'cron', 'feedback', 'taste', 'standing commission', 'auto-generate'] },
+ { id: 'nav.create.game', path: '/game', label: 'Game', section: 'Create', aliases: ['game', 'games', 'game-studio'], keywords: ['managed app', 'sprite', 'atlas', 'music', 'asset bundle', 'manifest', 'feedback'] },
{ id: 'nav.create.music-video', path: '/music-video', label: 'Music Video', section: 'Create', aliases: ['music-video', 'musicvideo', 'music video', 'mv', 'sync-to-beat'], keywords: ['music', 'beat', 'sync', 'audio-reactive', 'director', 'scene board', 'tempo', 'choreography'] },
{ id: 'nav.create.mood-boards', path: '/mood-boards', label: 'Mood Boards', section: 'Create', aliases: ['mood-boards', 'mood-board', 'moodboard', 'mood', 'inspiration', 'references'], keywords: ['inspiration', 'reference', 'pin', 'visual', 'canvas', 'collect', 'pinboard', 'palette', 'ideas'] },
// Promoted out of the Media Gen tabs to a top-level Create page (#2930).
diff --git a/server/lib/validation.js b/server/lib/validation.js
index 948193d48..54345ca2f 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -19,6 +19,7 @@ import {
import { QUEUEABLE_IMAGE_MODES } from '../services/imageGen/modes.js';
import { GROK_VIDEO_DURATIONS } from './grokVideoClip.js';
import { PR_COMPLETION_VALUES } from './prDisposition.js';
+import { EFFORT_LEVELS } from './providerModels.js';
// Clip lengths grok's image_to_video delivers, as a Zod union built from the
// single shared list (see grokVideoClip.js). `z.literal` per value rather than
@@ -314,6 +315,39 @@ export const referenceRepoUpdateSchema = z.object({
// either — all ref CRUD goes through /api/apps/:appId/reference-repos.
export const appUpdateSchema = partialWithoutDefaults(appSchema);
+// Game studio (#3177): managed-app binding, reusable asset bindings, bundle
+// compile, and user-triggered AI feedback.
+const gameNameSchema = z.string().trim().min(1).max(120);
+const gameAppIdSchema = z.string().trim().min(1).max(128);
+const gameAssetIdSchema = z.string().trim().min(1).max(128);
+
+export const gameCreateSchema = z.object({
+ appId: gameAppIdSchema,
+ name: gameNameSchema,
+}).strict();
+
+export const gameUpdateSchema = z.object({
+ appId: gameAppIdSchema.optional(),
+ name: gameNameSchema.optional(),
+}).strict().refine((patch) => Object.keys(patch).length > 0, {
+ message: 'at least one field is required',
+});
+
+export const gameSpriteBindingSchema = z.object({
+ spriteId: gameAssetIdSchema,
+}).strict();
+
+export const gameMusicBindingSchema = z.object({
+ trackId: gameAssetIdSchema,
+}).strict();
+
+export const gameFeedbackSchema = z.object({
+ providerId: z.string().trim().min(1).max(128),
+ model: z.string().trim().min(1).max(256).optional(),
+ effort: z.enum(EFFORT_LEVELS).nullable().optional(),
+ prompt: z.string().trim().min(1).max(4_000),
+}).strict();
+
// Provider schema
export const providerSchema = z.object({
name: z.string().min(1).max(100),
diff --git a/server/routes/games.js b/server/routes/games.js
new file mode 100644
index 000000000..9add3a171
--- /dev/null
+++ b/server/routes/games.js
@@ -0,0 +1,82 @@
+/**
+ * Game studio REST surface (#3177).
+ */
+
+import { Router } from 'express';
+import { asyncHandler, ServerError } from '../lib/errorHandler.js';
+import {
+ gameCreateSchema,
+ gameFeedbackSchema,
+ gameMusicBindingSchema,
+ gameSpriteBindingSchema,
+ gameUpdateSchema,
+ validateRequest,
+} from '../lib/validation.js';
+import {
+ bindMusic,
+ bindSprite,
+ compileGameAssets,
+ createGame,
+ deleteGame,
+ getGame,
+ listGames,
+ requestGameFeedback,
+ unbindMusic,
+ unbindSprite,
+ updateGame,
+} from '../services/games/index.js';
+
+const router = Router();
+
+router.get('/', asyncHandler(async (_req, res) => {
+ res.json(await listGames());
+}));
+
+router.post('/', asyncHandler(async (req, res) => {
+ const input = validateRequest(gameCreateSchema, req.body);
+ res.status(201).json(await createGame(input));
+}));
+
+router.get('/:id', asyncHandler(async (req, res) => {
+ const game = await getGame(req.params.id);
+ if (!game) throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ res.json(game);
+}));
+
+router.patch('/:id', asyncHandler(async (req, res) => {
+ const patch = validateRequest(gameUpdateSchema, req.body);
+ res.json(await updateGame(req.params.id, patch));
+}));
+
+router.delete('/:id', asyncHandler(async (req, res) => {
+ res.json(await deleteGame(req.params.id));
+}));
+
+router.post('/:id/sprites', asyncHandler(async (req, res) => {
+ const binding = validateRequest(gameSpriteBindingSchema, req.body);
+ res.status(201).json(await bindSprite(req.params.id, binding));
+}));
+
+router.delete('/:id/sprites/:spriteId', asyncHandler(async (req, res) => {
+ res.json(await unbindSprite(req.params.id, req.params.spriteId));
+}));
+
+router.post('/:id/music', asyncHandler(async (req, res) => {
+ const binding = validateRequest(gameMusicBindingSchema, req.body);
+ res.status(201).json(await bindMusic(req.params.id, binding));
+}));
+
+router.delete('/:id/music/:bindingId', asyncHandler(async (req, res) => {
+ res.json(await unbindMusic(req.params.id, req.params.bindingId));
+}));
+
+router.post('/:id/compile', asyncHandler(async (req, res) => {
+ res.json(await compileGameAssets(req.params.id));
+}));
+
+router.post('/:id/feedback', asyncHandler(async (req, res) => {
+ const input = validateRequest(gameFeedbackSchema, req.body);
+ res.status(201).json(await requestGameFeedback(req.params.id, input));
+}));
+
+export default router;
diff --git a/server/routes/games.test.js b/server/routes/games.test.js
new file mode 100644
index 000000000..fcc98a850
--- /dev/null
+++ b/server/routes/games.test.js
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import express from 'express';
+import { errorMiddleware } from '../lib/errorHandler.js';
+import { request } from '../lib/testHelper.js';
+
+vi.mock('../services/games/index.js', () => ({
+ bindMusic: vi.fn(),
+ bindSprite: vi.fn(),
+ compileGameAssets: vi.fn(),
+ createGame: vi.fn(),
+ deleteGame: vi.fn(),
+ getGame: vi.fn(),
+ listGames: vi.fn(async () => []),
+ requestGameFeedback: vi.fn(),
+ unbindMusic: vi.fn(),
+ unbindSprite: vi.fn(),
+ updateGame: vi.fn(),
+}));
+
+import * as games from '../services/games/index.js';
+import routes from './games.js';
+
+const makeApp = () => {
+ const app = express();
+ app.use(express.json());
+ app.use('/api/games', routes);
+ app.use(errorMiddleware);
+ return app;
+};
+
+describe('Game routes', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('creates a managed-app-bound Game', async () => {
+ games.createGame.mockResolvedValueOnce({ id: 'game-1', appId: 'app-1', name: 'Example Game' });
+ const response = await request(makeApp())
+ .post('/api/games')
+ .send({ appId: 'app-1', name: 'Example Game' });
+ expect(response.status).toBe(201);
+ expect(games.createGame).toHaveBeenCalledWith({ appId: 'app-1', name: 'Example Game' });
+ });
+
+ it('validates sprite bindings before dispatch', async () => {
+ const response = await request(makeApp())
+ .post('/api/games/game-1/sprites')
+ .send({ spriteId: '' });
+ expect(response.status).toBe(400);
+ expect(games.bindSprite).not.toHaveBeenCalled();
+ });
+
+ it('compiles a Game bundle', async () => {
+ games.compileGameAssets.mockResolvedValueOnce({ version: 2, created: true });
+ const response = await request(makeApp()).post('/api/games/game-1/compile');
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ version: 2, created: true });
+ });
+
+ it('passes explicit provider, model, effort, and prompt to feedback', async () => {
+ games.requestGameFeedback.mockResolvedValueOnce({
+ feedback: { id: 'feedback-1', text: 'Add a victory cue.' },
+ game: { id: 'game-1' },
+ });
+ const response = await request(makeApp())
+ .post('/api/games/game-1/feedback')
+ .send({
+ providerId: 'codex',
+ model: 'gpt-5.6-terra',
+ effort: 'high',
+ prompt: 'Review the asset coverage.',
+ });
+ expect(response.status).toBe(201);
+ expect(games.requestGameFeedback).toHaveBeenCalledWith('game-1', {
+ providerId: 'codex',
+ model: 'gpt-5.6-terra',
+ effort: 'high',
+ prompt: 'Review the asset coverage.',
+ });
+ });
+
+ it('returns 404 for an unknown Game detail', async () => {
+ games.getGame.mockResolvedValueOnce(null);
+ const response = await request(makeApp()).get('/api/games/missing');
+ expect(response.status).toBe(404);
+ expect(response.body.code).toBe('NOT_FOUND');
+ });
+});
diff --git a/server/scripts/init-db.sql b/server/scripts/init-db.sql
index f993f6692..cde97a268 100644
--- a/server/scripts/init-db.sql
+++ b/server/scripts/init-db.sql
@@ -755,6 +755,21 @@ CREATE TABLE IF NOT EXISTS sprite_records (
);
CREATE INDEX IF NOT EXISTS idx_sprite_records_live ON sprite_records (deleted) WHERE deleted = FALSE;
+-- Game studio (#3177). One row per managed-app asset plan; reusable sprite and
+-- music bindings, compile pointers/history, and user-requested AI feedback live
+-- in `data` JSONB. Compiled bundle manifests stay under
+-- data/games//manifests. Machine-local because the app registry and bound
+-- asset bytes are machine-local; no peer-sync tombstones.
+CREATE TABLE IF NOT EXISTS games (
+ id TEXT PRIMARY KEY,
+ app_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_games_app_updated ON games (app_id, updated_at DESC);
+
-- Mood boards (issue #911). A dedicated inspiration/mood-board canvas, distinct
-- from raw Media History, for collecting visual + textual references that feed
-- the Create suite. One row per board, the full record (name/description/items[])
@@ -1356,6 +1371,8 @@ DROP TRIGGER IF EXISTS trg_image_to_3d_models_audit ON image_to_3d_models;
CREATE TRIGGER trg_image_to_3d_models_audit AFTER UPDATE OR DELETE ON image_to_3d_models FOR EACH ROW EXECUTE FUNCTION record_audit_log();
DROP TRIGGER IF EXISTS trg_sprite_records_audit ON sprite_records;
CREATE TRIGGER trg_sprite_records_audit AFTER UPDATE OR DELETE ON sprite_records FOR EACH ROW EXECUTE FUNCTION record_audit_log();
+DROP TRIGGER IF EXISTS trg_games_audit ON games;
+CREATE TRIGGER trg_games_audit AFTER UPDATE OR DELETE ON games FOR EACH ROW EXECUTE FUNCTION record_audit_log();
DROP TRIGGER IF EXISTS trg_mood_boards_audit ON mood_boards;
CREATE TRIGGER trg_mood_boards_audit AFTER UPDATE OR DELETE ON mood_boards FOR EACH ROW EXECUTE FUNCTION record_audit_log();
DROP TRIGGER IF EXISTS trg_lora_training_runs_audit ON lora_training_runs;
diff --git a/server/services/bootstrap.js b/server/services/bootstrap.js
index 3ce2907cc..2afbf101e 100644
--- a/server/services/bootstrap.js
+++ b/server/services/bootstrap.js
@@ -110,6 +110,7 @@ import { mediaCollectionStore } from './mediaCollections.js';
import { loraDatasetStore } from './loraDatasets.js';
import { commissionStore, backfillAllCommissionFeedback } from './creativeCommissions/store.js';
import { outcomesStore as liOutcomesStore } from './layeredIntelligenceOutcomes.js';
+import * as gameStore from './games/store.js';
/**
* Pre-route boot. Everything a route handler may depend on being ready the
@@ -144,7 +145,7 @@ export const bootstrapServices = async ({ io, dataDir, dataReferenceDir, serverD
// but DO NOT crash the server. PortOS is single-user (CLAUDE.md "Security
// Model"); a hard exit on startup is worse than a noisy log the user can act
// on. Returns per-store statuses for downstream telemetry; we discard them.
- await verifyCollectionVersions([universeStore(), seriesStore(), issueStore(), conflictJournalStore(), storyBuilderStore(), mediaCollectionStore(), loraDatasetStore, liOutcomesStore(), commissionStore(), ...brainCollectionStores()]).catch(err => {
+ await verifyCollectionVersions([universeStore(), seriesStore(), issueStore(), conflictJournalStore(), storyBuilderStore(), mediaCollectionStore(), loraDatasetStore, liOutcomesStore(), commissionStore(), gameStore, ...brainCollectionStores()]).catch(err => {
console.error(`❌ Collection version check failed at startup: ${err?.stack ?? err}`);
});
diff --git a/server/services/games/README.md b/server/services/games/README.md
new file mode 100644
index 000000000..833dc2914
--- /dev/null
+++ b/server/services/games/README.md
@@ -0,0 +1,12 @@
+# Games
+
+The Game studio binds a managed app to reusable Sprite and Music records, compiles immutable versioned asset manifests, and stores user-triggered AI review history.
+
+| File | Purpose |
+|---|---|
+| `store.js` | PostgreSQL/collectionStore dispatcher plus per-Game write serialization and manifest directory paths. |
+| `db.js` | PostgreSQL leaf I/O for `games` JSONB records. |
+| `records.js` | Game CRUD, managed-app validation, and sprite/music bind/unbind operations. |
+| `compile.js` | Deterministic, idempotent asset-manifest compiler with SHA-256 references. |
+| `feedback.js` | Explicitly user-triggered provider/model/effort feedback and history persistence. |
+| `index.js` | Public Game service barrel. |
diff --git a/server/services/games/compile.js b/server/services/games/compile.js
new file mode 100644
index 000000000..ed4aa914e
--- /dev/null
+++ b/server/services/games/compile.js
@@ -0,0 +1,149 @@
+/**
+ * Deterministic Game asset-bundle compiler.
+ *
+ * The manifest references immutable sprite atlas versions and music-library
+ * bytes by SHA-256. Recompiling identical inputs returns the current pointer
+ * without writing a new version.
+ */
+
+import { createHash } from 'crypto';
+import { join } from 'path';
+import { ServerError } from '../../lib/errorHandler.js';
+import { atomicWrite, pathExists, PATHS, sha256File } from '../../lib/fileUtils.js';
+import { canonicalStringify } from '../../lib/objects.js';
+import { getAppById } from '../apps.js';
+import { statMusicTrack } from '../pipeline/musicLibrary.js';
+import { getAtlasState } from '../sprites/atlas.js';
+import { getRecord as getSpriteRecord } from '../sprites/records.js';
+import { getTrack } from '../tracks/index.js';
+import { GAME_HISTORY_LIMIT, sanitizeGame } from './records.js';
+import { gameRecordDir, isValidGameId, queueGameWrite, readRaw, writeRaw } from './store.js';
+
+const sha256Text = (value) => createHash('sha256').update(value).digest('hex');
+
+async function resolveSprite(binding) {
+ const [record, atlas] = await Promise.all([
+ getSpriteRecord(binding.spriteId),
+ getAtlasState(binding.spriteId),
+ ]);
+ if (!record) {
+ throw new ServerError(`Bound sprite no longer exists: ${binding.spriteId}`, {
+ status: 409,
+ code: 'SPRITE_MISSING',
+ });
+ }
+ if (!atlas.current?.atlasPath || !atlas.current?.atlasSha256
+ || !atlas.current?.manifestPath || !atlas.current?.manifestSha256) {
+ throw new ServerError(`Compile an atlas for "${record.name}" before building the game bundle`, {
+ status: 409,
+ code: 'SPRITE_ATLAS_REQUIRED',
+ });
+ }
+ return {
+ spriteId: record.id,
+ name: record.name,
+ kind: record.kind,
+ atlasVersion: atlas.current.version,
+ atlasPath: `sprites/${record.id}/${atlas.current.atlasPath}`,
+ atlasSha256: atlas.current.atlasSha256,
+ manifestPath: `sprites/${record.id}/${atlas.current.manifestPath}`,
+ manifestSha256: atlas.current.manifestSha256,
+ geometry: atlas.current.geometry,
+ };
+}
+
+async function resolveMusic(binding) {
+ const track = await getTrack(binding.trackId);
+ if (!track) {
+ throw new ServerError(`Bound music track no longer exists: ${binding.trackId}`, {
+ status: 409,
+ code: 'TRACK_MISSING',
+ });
+ }
+ if (!track.audioFilename || !(await statMusicTrack(track.audioFilename))) {
+ throw new ServerError(`Render or upload audio for "${track.title}" before building the game bundle`, {
+ status: 409,
+ code: 'TRACK_AUDIO_REQUIRED',
+ });
+ }
+ return {
+ bindingId: binding.id,
+ trackId: track.id,
+ title: track.title,
+ audioPath: `music/${track.audioFilename}`,
+ audioSha256: await sha256File(join(PATHS.music, track.audioFilename)),
+ };
+}
+
+const manifestPathFor = (version) => `manifests/game-assets-v${version}.json`;
+
+export async function compileGameAssets(id) {
+ if (!isValidGameId(id)) {
+ throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ }
+ let result;
+ await queueGameWrite(id, async () => {
+ const game = sanitizeGame(await readRaw(id));
+ if (!game) throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ const app = await getAppById(game.appId);
+ if (!app) throw new ServerError('The bound managed app no longer exists', { status: 409, code: 'APP_MISSING' });
+
+ const [sprites, music] = await Promise.all([
+ Promise.all([...game.spriteBindings]
+ .sort((a, b) => a.spriteId.localeCompare(b.spriteId))
+ .map(resolveSprite)),
+ Promise.all([...game.musicBindings]
+ .sort((a, b) => a.trackId.localeCompare(b.trackId) || a.id.localeCompare(b.id))
+ .map(resolveMusic)),
+ ]);
+ const inputs = { schemaVersion: 1, gameId: game.id, appId: game.appId, sprites, music };
+ const inputSha256 = sha256Text(canonicalStringify(inputs));
+ const current = game.compiledManifest;
+ const currentPath = current?.manifestPath
+ ? join(gameRecordDir(id), current.manifestPath)
+ : null;
+ if (current?.inputSha256 === inputSha256
+ && current.manifestSha256
+ && currentPath
+ && await pathExists(currentPath)
+ && await sha256File(currentPath) === current.manifestSha256) {
+ result = { ...current, created: false };
+ return;
+ }
+
+ const version = Math.max(0, ...game.compileHistory.map((entry) =>
+ Number.isInteger(entry.version) ? entry.version : 0)) + 1;
+ const builtAt = new Date().toISOString();
+ const manifest = {
+ schemaVersion: 1,
+ kind: 'portos-game-assets',
+ game: { id: game.id, name: game.name, appId: game.appId },
+ version,
+ builtAt,
+ sprites,
+ music,
+ };
+ const serialized = `${canonicalStringify(manifest)}\n`;
+ const manifestPath = manifestPathFor(version);
+ await atomicWrite(join(gameRecordDir(id), manifestPath), serialized);
+ const pointer = {
+ schemaVersion: 1,
+ version,
+ builtAt,
+ manifestPath,
+ manifestSha256: sha256Text(serialized),
+ inputSha256,
+ spriteCount: sprites.length,
+ musicCount: music.length,
+ };
+ const next = sanitizeGame({
+ ...game,
+ compiledManifest: pointer,
+ compileHistory: [...game.compileHistory, pointer].slice(-GAME_HISTORY_LIMIT),
+ updatedAt: builtAt,
+ });
+ await writeRaw(id, next);
+ result = { ...pointer, created: true };
+ });
+ return result;
+}
diff --git a/server/services/games/compile.test.js b/server/services/games/compile.test.js
new file mode 100644
index 000000000..abb0803ca
--- /dev/null
+++ b/server/services/games/compile.test.js
@@ -0,0 +1,99 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const state = vi.hoisted(() => ({
+ game: null,
+ manifestExists: false,
+ manifestSha256: null,
+}));
+
+vi.mock('./store.js', () => ({
+ gameRecordDir: (id) => `/tmp/games/${id}`,
+ isValidGameId: (id) => id === 'game-1',
+ queueGameWrite: vi.fn(async (_id, fn) => fn()),
+ readRaw: vi.fn(async () => state.game),
+ writeRaw: vi.fn(async (_id, record) => {
+ state.game = record;
+ return record;
+ }),
+}));
+
+vi.mock('../../lib/fileUtils.js', () => ({
+ PATHS: { music: '/tmp/music' },
+ atomicWrite: vi.fn(async () => { state.manifestExists = true; }),
+ pathExists: vi.fn(async () => state.manifestExists),
+ sha256File: vi.fn(async (path) =>
+ path.includes('/manifests/') ? state.manifestSha256 : 'audio-sha'),
+}));
+
+vi.mock('../apps.js', () => ({
+ getAppById: vi.fn(async () => ({ id: 'app-1', name: 'Example App' })),
+}));
+
+vi.mock('../sprites/records.js', () => ({
+ getRecord: vi.fn(async (id) => ({ id, name: `Sprite ${id}`, kind: 'character' })),
+}));
+
+vi.mock('../sprites/atlas.js', () => ({
+ getAtlasState: vi.fn(async (id) => ({
+ current: {
+ version: 3,
+ atlasPath: `runtime/v3/${id}.png`,
+ atlasSha256: `atlas-${id}`,
+ manifestPath: `runtime/v3/${id}.json`,
+ manifestSha256: `manifest-${id}`,
+ geometry: { frameWidth: 32, frameHeight: 32 },
+ },
+ publications: [],
+ })),
+}));
+
+vi.mock('../tracks/index.js', () => ({
+ getTrack: vi.fn(async (id) => ({ id, title: `Track ${id}`, audioFilename: `${id}.ogg` })),
+}));
+
+vi.mock('../pipeline/musicLibrary.js', () => ({
+ statMusicTrack: vi.fn(async (filename) => ({ filename, sizeBytes: 100 })),
+}));
+
+import { atomicWrite } from '../../lib/fileUtils.js';
+import { compileGameAssets } from './compile.js';
+
+describe('compileGameAssets', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.manifestExists = false;
+ state.manifestSha256 = null;
+ state.game = {
+ id: 'game-1',
+ schemaVersion: 1,
+ appId: 'app-1',
+ name: 'Example Game',
+ spriteBindings: [{ spriteId: 'zeta' }, { spriteId: 'alpha' }],
+ musicBindings: [
+ { id: 'music-2', trackId: 'track-z' },
+ { id: 'music-1', trackId: 'track-a' },
+ ],
+ compiledManifest: null,
+ compileHistory: [],
+ feedbackHistory: [],
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ };
+ });
+
+ it('writes a stable, sorted manifest and reuses it for identical inputs', async () => {
+ const first = await compileGameAssets('game-1');
+ expect(first.created).toBe(true);
+ expect(first.version).toBe(1);
+ expect(atomicWrite).toHaveBeenCalledTimes(1);
+ const [, serialized] = atomicWrite.mock.calls[0];
+ const manifest = JSON.parse(serialized);
+ state.manifestSha256 = first.manifestSha256;
+ expect(manifest.sprites.map((sprite) => sprite.spriteId)).toEqual(['alpha', 'zeta']);
+ expect(manifest.music.map((track) => track.trackId)).toEqual(['track-a', 'track-z']);
+
+ const second = await compileGameAssets('game-1');
+ expect(second).toEqual({ ...state.game.compiledManifest, created: false });
+ expect(atomicWrite).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/server/services/games/db.js b/server/services/games/db.js
new file mode 100644
index 000000000..da5ff7fce
--- /dev/null
+++ b/server/services/games/db.js
@@ -0,0 +1,38 @@
+/**
+ * PostgreSQL leaf I/O for Game records. The full sanitized record is stored in
+ * JSONB; app_id/name/updated_at are mirrored for the common list/filter paths.
+ */
+
+import { query } from '../../lib/db.js';
+import { mirrorTimestamp } from '../../lib/pgTimestamp.js';
+
+export async function readRaw(id) {
+ const { rows } = await query('SELECT data FROM games WHERE id = $1', [id]);
+ return rows[0]?.data ?? null;
+}
+
+export async function listRaw() {
+ const { rows } = await query('SELECT data FROM games ORDER BY updated_at DESC, id ASC');
+ return rows.map((row) => row.data);
+}
+
+export async function writeRaw(id, record) {
+ const now = new Date().toISOString();
+ const createdAt = mirrorTimestamp(record?.createdAt, now);
+ const updatedAt = mirrorTimestamp(record?.updatedAt, createdAt);
+ await query(
+ `INSERT INTO games (id, app_id, name, data, created_at, updated_at)
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6)
+ ON CONFLICT (id) DO UPDATE SET
+ app_id = EXCLUDED.app_id,
+ name = EXCLUDED.name,
+ data = EXCLUDED.data,
+ updated_at = EXCLUDED.updated_at`,
+ [id, record.appId, record.name, JSON.stringify(record), createdAt, updatedAt],
+ );
+ return record;
+}
+
+export async function deleteRaw(id) {
+ await query('DELETE FROM games WHERE id = $1', [id]);
+}
diff --git a/server/services/games/feedback.js b/server/services/games/feedback.js
new file mode 100644
index 000000000..d91ce04de
--- /dev/null
+++ b/server/services/games/feedback.js
@@ -0,0 +1,90 @@
+/**
+ * User-triggered AI feedback for a Game's asset plan.
+ */
+
+import { randomUUID } from 'crypto';
+import { ServerError } from '../../lib/errorHandler.js';
+import { buildEffortArgs } from '../../lib/providerModels.js';
+import {
+ resolveEffectiveModel,
+ runPromptThroughProvider,
+} from '../../lib/promptRunner.js';
+import { getAppById } from '../apps.js';
+import { getProviderById } from '../providers.js';
+import { getRecord as getSpriteRecord } from '../sprites/records.js';
+import { getTrack } from '../tracks/index.js';
+import { GAME_HISTORY_LIMIT, getGame, mutateGame } from './records.js';
+
+async function buildGameFeedbackPrompt(game, request) {
+ const [app, sprites, tracks] = await Promise.all([
+ getAppById(game.appId),
+ Promise.all(game.spriteBindings.map((binding) => getSpriteRecord(binding.spriteId))),
+ Promise.all(game.musicBindings.map((binding) => getTrack(binding.trackId))),
+ ]);
+ if (!app) throw new ServerError('The bound managed app no longer exists', { status: 409, code: 'APP_MISSING' });
+
+ const assetSummary = {
+ game: game.name,
+ app: {
+ name: app.name,
+ type: app.type || 'unknown',
+ description: app.description || '',
+ },
+ sprites: sprites.filter(Boolean).map((sprite) => ({
+ id: sprite.id,
+ name: sprite.name,
+ kind: sprite.kind,
+ status: sprite.status,
+ })),
+ music: tracks.filter(Boolean).map((track) => ({
+ id: track.id,
+ title: track.title,
+ hasAudio: Boolean(track.audioFilename),
+ })),
+ compiledManifest: game.compiledManifest,
+ };
+ return `You are reviewing an asset bundle plan for a game project.
+
+Give concise, actionable feedback about coverage, visual/audio cohesion, missing gameplay-facing assets, and the next highest-value improvements. Do not propose implementing a game engine or changing the managed app's repository.
+
+USER REQUEST:
+${request}
+
+GAME ASSET PLAN:
+${JSON.stringify(assetSummary, null, 2)}`;
+}
+
+export async function requestGameFeedback(id, { providerId, model, effort, prompt }) {
+ const game = await getGame(id);
+ if (!game) throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ const provider = await getProviderById(providerId);
+ if (!provider) {
+ throw new ServerError('AI provider not found', { status: 404, code: 'PROVIDER_NOT_FOUND' });
+ }
+ if (provider.enabled === false) {
+ throw new ServerError('Choose an enabled AI provider', { status: 400, code: 'PROVIDER_UNAVAILABLE' });
+ }
+ const selectedModel = resolveEffectiveModel(provider, model);
+ const effortArgs = buildEffortArgs(effort, provider, provider.args || []);
+ const run = await runPromptThroughProvider({
+ provider: effortArgs.length ? { ...provider, args: [...(provider.args || []), ...effortArgs] } : provider,
+ model: selectedModel ?? undefined,
+ prompt: await buildGameFeedbackPrompt(game, prompt),
+ source: 'game-asset-feedback',
+ });
+ const createdAt = new Date().toISOString();
+ const feedback = {
+ id: `feedback-${randomUUID()}`,
+ prompt,
+ text: run.text,
+ providerId: run.provider?.id || run.fallbackProvider?.id || provider.id,
+ model: run.model || selectedModel || provider.defaultModel || null,
+ effort: effort || null,
+ createdAt,
+ };
+ const updated = await mutateGame(id, (current) => ({
+ ...current,
+ feedbackHistory: [...current.feedbackHistory, feedback].slice(-GAME_HISTORY_LIMIT),
+ }));
+ return { feedback, game: updated };
+}
diff --git a/server/services/games/feedback.test.js b/server/services/games/feedback.test.js
new file mode 100644
index 000000000..d9d626607
--- /dev/null
+++ b/server/services/games/feedback.test.js
@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const state = vi.hoisted(() => ({
+ game: null,
+}));
+
+vi.mock('./records.js', () => ({
+ GAME_HISTORY_LIMIT: 50,
+ getGame: vi.fn(async () => state.game),
+ mutateGame: vi.fn(async (_id, mutator) => {
+ state.game = await mutator(state.game);
+ return state.game;
+ }),
+}));
+
+vi.mock('../providers.js', () => ({
+ getProviderById: vi.fn(async () => ({
+ id: 'codex',
+ name: 'Codex',
+ command: 'codex',
+ type: 'cli',
+ args: [],
+ enabled: true,
+ defaultModel: 'gpt-5.6-terra',
+ })),
+}));
+
+vi.mock('../../lib/promptRunner.js', () => ({
+ resolveEffectiveModel: vi.fn((_provider, model) => model),
+ runPromptThroughProvider: vi.fn(async () => ({
+ text: 'Add a victory cue.',
+ provider: { id: 'codex' },
+ model: 'gpt-5.6-terra',
+ })),
+}));
+
+vi.mock('../apps.js', () => ({
+ getAppById: vi.fn(async () => ({ id: 'app-1', name: 'Example App', type: 'godot' })),
+}));
+
+vi.mock('../sprites/records.js', () => ({
+ getRecord: vi.fn(async (id) => ({ id, name: 'Example Hero', kind: 'character', status: 'ready' })),
+}));
+
+vi.mock('../tracks/index.js', () => ({
+ getTrack: vi.fn(async (id) => ({ id, title: 'Example Theme', audioFilename: 'theme.ogg' })),
+}));
+
+import { runPromptThroughProvider } from '../../lib/promptRunner.js';
+import { getProviderById } from '../providers.js';
+import { requestGameFeedback } from './feedback.js';
+
+describe('requestGameFeedback', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.game = {
+ id: 'game-1',
+ name: 'Example Game',
+ appId: 'app-1',
+ spriteBindings: [{ spriteId: 'hero' }],
+ musicBindings: [{ id: 'music-1', trackId: 'theme' }],
+ compiledManifest: null,
+ feedbackHistory: [],
+ };
+ });
+
+ it('runs the exact selected provider/model/effort and persists the response', async () => {
+ const result = await requestGameFeedback('game-1', {
+ providerId: 'codex',
+ model: 'gpt-5.6-terra',
+ effort: 'high',
+ prompt: 'Review the bundle.',
+ });
+ expect(getProviderById).toHaveBeenCalledWith('codex');
+ expect(runPromptThroughProvider).toHaveBeenCalledWith(expect.objectContaining({
+ provider: expect.objectContaining({
+ id: 'codex',
+ args: ['-c', 'model_reasoning_effort=high'],
+ }),
+ model: 'gpt-5.6-terra',
+ source: 'game-asset-feedback',
+ }));
+ expect(result.feedback).toEqual(expect.objectContaining({
+ text: 'Add a victory cue.',
+ providerId: 'codex',
+ model: 'gpt-5.6-terra',
+ effort: 'high',
+ }));
+ expect(result.game.feedbackHistory).toHaveLength(1);
+ });
+
+ it('does not silently substitute a different provider', async () => {
+ getProviderById.mockResolvedValueOnce(null);
+ await expect(requestGameFeedback('game-1', {
+ providerId: 'missing',
+ model: 'example-model',
+ prompt: 'Review the bundle.',
+ })).rejects.toMatchObject({ status: 404, code: 'PROVIDER_NOT_FOUND' });
+ expect(runPromptThroughProvider).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/services/games/index.js b/server/services/games/index.js
new file mode 100644
index 000000000..39ea316ae
--- /dev/null
+++ b/server/services/games/index.js
@@ -0,0 +1,22 @@
+export {
+ GAME_HISTORY_LIMIT,
+ bindMusic,
+ bindSprite,
+ createGame,
+ deleteGame,
+ getGame,
+ listGames,
+ mutateGame,
+ sanitizeGame,
+ unbindMusic,
+ unbindSprite,
+ updateGame,
+} from './records.js';
+export { compileGameAssets } from './compile.js';
+export { requestGameFeedback } from './feedback.js';
+export {
+ _resetGamesBackend,
+ gameRecordDir,
+ isValidGameId,
+ verifySchemaVersion,
+} from './store.js';
diff --git a/server/services/games/records.js b/server/services/games/records.js
new file mode 100644
index 000000000..0f8148b0c
--- /dev/null
+++ b/server/services/games/records.js
@@ -0,0 +1,188 @@
+/**
+ * Game record lifecycle and asset bindings.
+ */
+
+import { randomUUID } from 'crypto';
+import { ServerError } from '../../lib/errorHandler.js';
+import { getAppById } from '../apps.js';
+import { getRecord as getSpriteRecord } from '../sprites/records.js';
+import { getTrack } from '../tracks/index.js';
+import {
+ deleteRaw,
+ isValidGameId,
+ listRaw,
+ queueGameWrite,
+ readRaw,
+ writeRaw,
+} from './store.js';
+
+const HISTORY_LIMIT = 50;
+
+const objectArray = (value) =>
+ (Array.isArray(value) ? value.filter((entry) => entry && typeof entry === 'object') : []);
+const boundedHistory = (value) => objectArray(value).slice(-HISTORY_LIMIT);
+
+export function sanitizeGame(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ if (typeof raw.id !== 'string' || typeof raw.appId !== 'string') return null;
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
+ if (!name) return null;
+ return {
+ id: raw.id,
+ schemaVersion: 1,
+ appId: raw.appId,
+ name,
+ spriteBindings: objectArray(raw.spriteBindings).filter((binding) => typeof binding.spriteId === 'string'),
+ musicBindings: objectArray(raw.musicBindings).filter((binding) =>
+ typeof binding.id === 'string' && typeof binding.trackId === 'string'),
+ compiledManifest: raw.compiledManifest && typeof raw.compiledManifest === 'object'
+ ? raw.compiledManifest
+ : null,
+ compileHistory: boundedHistory(raw.compileHistory),
+ feedbackHistory: boundedHistory(raw.feedbackHistory),
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : new Date().toISOString(),
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : new Date().toISOString(),
+ };
+}
+
+const requireGameRaw = async (id) => {
+ if (!isValidGameId(id)) {
+ throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ }
+ const game = sanitizeGame(await readRaw(id));
+ if (!game) throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ return game;
+};
+
+const requireApp = async (appId) => {
+ const app = await getAppById(appId);
+ if (!app) throw new ServerError('Managed app not found', { status: 400, code: 'INVALID_APP' });
+ return app;
+};
+
+export async function listGames() {
+ const records = (await listRaw()).map(sanitizeGame).filter(Boolean);
+ return records.sort((a, b) =>
+ b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name));
+}
+
+export async function getGame(id) {
+ if (!isValidGameId(id)) return null;
+ return sanitizeGame(await readRaw(id));
+}
+
+export async function createGame({ appId, name }) {
+ await requireApp(appId);
+ const now = new Date().toISOString();
+ const game = sanitizeGame({
+ id: `game-${randomUUID()}`,
+ schemaVersion: 1,
+ appId,
+ name,
+ spriteBindings: [],
+ musicBindings: [],
+ compiledManifest: null,
+ compileHistory: [],
+ feedbackHistory: [],
+ createdAt: now,
+ updatedAt: now,
+ });
+ await writeRaw(game.id, game);
+ return game;
+}
+
+export function mutateGame(id, mutator) {
+ if (!isValidGameId(id)) {
+ throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ }
+ return queueGameWrite(id, async () => {
+ const current = await requireGameRaw(id);
+ const changed = await mutator(current);
+ if (!changed) return current;
+ const next = sanitizeGame({ ...changed, id, updatedAt: new Date().toISOString() });
+ if (!next) throw new ServerError('Invalid Game record', { status: 400, code: 'VALIDATION_ERROR' });
+ await writeRaw(id, next);
+ return next;
+ });
+}
+
+export async function updateGame(id, patch) {
+ if (patch.appId != null) await requireApp(patch.appId);
+ return mutateGame(id, (current) => ({
+ ...current,
+ ...(patch.appId != null ? { appId: patch.appId } : {}),
+ ...(patch.name != null ? { name: patch.name } : {}),
+ }));
+}
+
+export async function deleteGame(id) {
+ if (!isValidGameId(id)) {
+ throw new ServerError('Game not found', { status: 404, code: 'NOT_FOUND' });
+ }
+ return queueGameWrite(id, async () => {
+ await requireGameRaw(id);
+ await deleteRaw(id);
+ return { id };
+ });
+}
+
+export async function bindSprite(id, { spriteId }) {
+ const sprite = await getSpriteRecord(spriteId);
+ if (!sprite) throw new ServerError('Sprite record not found', { status: 400, code: 'INVALID_SPRITE' });
+ return mutateGame(id, (current) => {
+ if (current.spriteBindings.some((binding) => binding.spriteId === spriteId)) {
+ throw new ServerError('Sprite is already bound to this game', { status: 409, code: 'ALREADY_BOUND' });
+ }
+ return {
+ ...current,
+ spriteBindings: [...current.spriteBindings, {
+ spriteId,
+ boundAt: new Date().toISOString(),
+ }],
+ };
+ });
+}
+
+export async function unbindSprite(id, spriteId) {
+ return mutateGame(id, (current) => {
+ if (!current.spriteBindings.some((binding) => binding.spriteId === spriteId)) {
+ throw new ServerError('Sprite binding not found', { status: 404, code: 'NOT_FOUND' });
+ }
+ return {
+ ...current,
+ spriteBindings: current.spriteBindings.filter((binding) => binding.spriteId !== spriteId),
+ };
+ });
+}
+
+export async function bindMusic(id, { trackId }) {
+ const track = await getTrack(trackId);
+ if (!track) throw new ServerError('Music track not found', { status: 400, code: 'INVALID_TRACK' });
+ return mutateGame(id, (current) => {
+ if (current.musicBindings.some((binding) => binding.trackId === trackId)) {
+ throw new ServerError('Music track is already bound to this game', { status: 409, code: 'ALREADY_BOUND' });
+ }
+ return {
+ ...current,
+ musicBindings: [...current.musicBindings, {
+ id: `music-${randomUUID()}`,
+ trackId,
+ boundAt: new Date().toISOString(),
+ }],
+ };
+ });
+}
+
+export async function unbindMusic(id, bindingId) {
+ return mutateGame(id, (current) => {
+ if (!current.musicBindings.some((binding) => binding.id === bindingId)) {
+ throw new ServerError('Music binding not found', { status: 404, code: 'NOT_FOUND' });
+ }
+ return {
+ ...current,
+ musicBindings: current.musicBindings.filter((binding) => binding.id !== bindingId),
+ };
+ });
+}
+
+export const GAME_HISTORY_LIMIT = HISTORY_LIMIT;
diff --git a/server/services/games/store.js b/server/services/games/store.js
new file mode 100644
index 000000000..feb7198d9
--- /dev/null
+++ b/server/services/games/store.js
@@ -0,0 +1,102 @@
+/**
+ * Game records — PostgreSQL/file backend facade.
+ *
+ * Games are db-primary in normal installs. The collectionStore backend keeps
+ * the domain runnable in tests and under the unsupported MEMORY_BACKEND=file
+ * development escape hatch. Compiled manifest assets live beside the record
+ * directory under data/games//manifests on both backends.
+ */
+
+import { join } from 'path';
+import { PATHS } from '../../lib/fileUtils.js';
+import { createCollectionStore } from '../../lib/collectionStore.js';
+import { createRecordWriteQueue } from '../../lib/fileWriteQueue.js';
+import { createPgFileFacade, resolvePgBackend } from '../../lib/pgFileFacade.js';
+
+const TYPE_SCHEMA_VERSION = 1;
+const ID_PATTERN = /^game-[A-Za-z0-9-]{1,80}$/;
+export const isValidGameId = (id) => typeof id === 'string' && ID_PATTERN.test(id);
+
+function assertId(id) {
+ if (!isValidGameId(id)) {
+ throw new Error(`games: invalid record id "${id}"`);
+ }
+}
+
+const gamesDir = () => join(PATHS.data, 'games');
+
+function makeFileBackend() {
+ const collection = createCollectionStore({
+ dir: gamesDir(),
+ type: 'games',
+ schemaVersion: TYPE_SCHEMA_VERSION,
+ idPattern: ID_PATTERN,
+ });
+
+ return {
+ name: 'file',
+ readRaw: (id) => collection.loadOneRaw(id),
+ listRaw: () => collection.loadAll(),
+ writeRaw: (id, record) => collection.saveOneNow(id, record),
+ deleteRaw: (id) => collection.deleteOneNow(id),
+ verify: () => collection.verifySchemaVersion(),
+ };
+}
+
+function makePgBackend(db) {
+ return {
+ name: 'postgres',
+ readRaw: db.readRaw,
+ listRaw: db.listRaw,
+ writeRaw: db.writeRaw,
+ deleteRaw: db.deleteRaw,
+ verify: async () => ({
+ ok: true,
+ type: 'games',
+ onDisk: null,
+ expected: null,
+ message: 'collection "games" @ postgres (#3177)',
+ }),
+ };
+}
+
+const facade = createPgFileFacade({
+ makeFile: makeFileBackend,
+ makePg: () => resolvePgBackend({
+ requirement: 'Games require PostgreSQL — run `npm run setup:db` (dev/test only: set MEMORY_BACKEND=file for the unsupported file backend)',
+ loadDb: () => import('./db.js'),
+ makePg: makePgBackend,
+ }),
+});
+
+const queueRecordWrite = createRecordWriteQueue(assertId);
+
+export const gameRecordDir = (id) => {
+ assertId(id);
+ return join(gamesDir(), id);
+};
+
+export const readRaw = async (id) => {
+ assertId(id);
+ return (await facade.getBackend()).readRaw(id);
+};
+
+export const listRaw = async () => (await facade.getBackend()).listRaw();
+
+export const writeRaw = async (id, record) => {
+ assertId(id);
+ return (await facade.getBackend()).writeRaw(id, record);
+};
+
+export const deleteRaw = async (id) => {
+ assertId(id);
+ return (await facade.getBackend()).deleteRaw(id);
+};
+
+export const queueGameWrite = (id, fn) => queueRecordWrite(id, fn);
+
+export const verifySchemaVersion = async () => (await facade.getBackend()).verify();
+
+export function _resetGamesBackend() {
+ facade.reset();
+}
]