import type { EditorDocument } from "../editor/types.js"; import type { ProjectileTravelDeps } from "../combat/projectiles.js"; import type { ActionCodesMap } from "../input/actionBindings.js"; import type { GameFeedOptions } from "./feed.js"; import type { GamePhase } from "./gamePhase.js"; import type { SystemDefinition } from "./defineSystem.js"; import type { ItemTraits } from "../inventory/inventoryModel.js"; import type { StorageTier } from "../inventory/storageTier.js"; import type { GameContext } from "../runtime/gameContext.js"; import type { ReplicationPolicy } from "../runtime/worldProjection.js"; import type { RuntimeSaveMode, RuntimeSaveOptions } from "../runtime/runtimeSave.js"; import type { SaveConfig } from "../runtime/save.js"; import { type AssetCatalog, type ModelAssetRef } from "../scene/assetCatalog.js"; import { type EntityStore } from "../scene/entityStore.js"; import type { StoreHandle } from "../store/defineStore.js"; import type { TimeConfig } from "../time/simClock.js"; import type { SimulationConfig } from "../runtime/simLoop.js"; import type { WorldFeature } from "../world/features.js"; import type { PhysicsBackend } from "../physics/physicsBackend.js"; import type { CharacterControllerConfig } from "../movement/characterController.js"; /** Tunes offline whole-world save (`defineGameDefinition({ persist })`). Offline games already autosave to `localStorage` by default — pass this only to tune it (or `persist: false` to opt out). Defaults: continuous `autosave` to `localStorage`, one slot, no version. */ export interface PersistConfig { /** `"autosave"` (default) writes on a trailing timer while the world changes — at most once per `autosaveMs`, so a never-idle world (AI, physics, day/night) still persists; `"manual"` writes only on `ctx.game.save.checkpoint()` (save points / quest triggers). */ mode?: RuntimeSaveMode; /** `"local"` (default) persists to `localStorage`; `"memory"` keeps saves in-session only (tests, "no persistence" mode). */ storage?: "local" | "memory"; /** Save-format version. Supply `migrate` for incompatible older shapes; without it, mismatched versions retain compatible load behavior. */ version?: number; /** Convert a mismatched version (or raw payload, version 0) before world restoration. Throw to reject without hydrating or changing the stored save. Current-version payloads bypass this hook. */ migrate?: RuntimeSaveOptions["migrate"]; /** Autosave debounce in ms (autosave mode). Default 3000. */ autosaveMs?: number; } /** World gravity and jump tuning, plus the optional rigid-body backend and character capsule. */ export interface PhysicsConfig { gravity?: number; jumpVelocity?: number; /** Physics solver used by the shell movement controller and the context's physics stage. */ backend?: PhysicsBackend; /** Capsule dimensions for players using the configured physics backend. */ controller?: CharacterControllerConfig; /** Opt placed scene objects into projectile raycasts as blocking cover AABBs. Default false — enabling changes where shots land. */ projectileObstacles?: boolean; } /** Shape of one named inventory a game declares — slot count, accepted item types, HUD binding. */ export interface InventoryDeclaration { slots: number; hud?: string; accepts?: string | readonly string[]; traits?: ItemTraits; applyModifiers?: boolean; tier?: StorageTier; } /** Hosting mode for a game's multiplayer server: `"persistent"`, or a custom mode with its own options. */ export type GameServerConfig = "persistent" | { mode: string; [key: string]: unknown; }; /** Identity of a player joining or leaving a hosted world — passed to the multiplayer loop hooks. */ export interface LoopPlayer { userId: string; isNew: boolean; } /** Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave. */ export interface GameLoop { onInit?(ctx: TContext): void; /** Once per join. `player` identifies the joiner in a hosted world; single-player callers may omit it. */ onNewPlayer?(ctx: TContext, player?: LoopPlayer): void; onTick?(ctx: TContext, dt: number): void; /** Once per leave in a hosted world (never fired single-player). Despawn the player's entities / release their slots here. */ onPlayerLeave?(ctx: TContext, player: LoopPlayer): void; /** Scenario/run reset — clear run-scoped state. Composed system `reset` hooks run here too. */ onReset?(ctx: TContext): void; /** World teardown (mode switch, unmount). Composed system `dispose` hooks run here too. */ onDispose?(ctx: TContext): void; } /** * A scope cleanups register onto, run in LIFO order exactly once — the shared shape behind every * teardown path in this codebase (a looping audio handle's source/gain nodes, a GPU texture swap on * entity/scene replacement). Registering after `dispose()` has already run invokes the cleanup * immediately instead of dropping it, so a resource created by a still-in-flight async step (e.g. a * decoded audio buffer) never leaks past a `stop()`/unmount that raced ahead of it. * @internal */ export interface DisposerScope { onDispose(cleanup: () => void): void; } /** @internal */ export interface Disposer extends DisposerScope { dispose(): void; } /** @internal */ export declare function createDisposer(): Disposer; /** * Opt-in `ctx.game.*` subsystems. Absent = off: the game doesn't carry (or expose) it, and `ctx.game.` * is `undefined`. Present (`true`) builds it. The universal base — `commands`, `events`, `store`, `feed` — is * always on and not listed here. This is what keeps core genre-agnostic: a puzzle game isn't handed an MMO's * leaderboard/roster/turn plumbing it never asked for. */ export interface GameFeatures { /** Quest/mission journal (`ctx.game.quest`) — quest-driven games. */ quest?: boolean; /** Shop/vendor barter (`ctx.game.trade`) — games with buy/sell economies. */ trade?: boolean; /** Earned unlockable content (`ctx.game.unlocks`) — progression-gated games. */ unlocks?: boolean; /** Cosmetic skins/customization (`ctx.player.cosmetics`) — appearance-customization games. */ cosmetics?: boolean; /** Owned/captured entity roster (`ctx.game.roster`) — pet/monster collection games. */ roster?: boolean; /** Card pile zones (`ctx.game.cards`) — deckbuilders, card games. */ cards?: boolean; /** Turn/phase loop (`ctx.game.turn`) — turn-based games. */ turn?: boolean; /** Talkable-NPC dialogue bridge (`ctx.game.dialogue`) — auto-registers `dialogue.open`/`dialogue.close`; dialogue-driven games. */ dialogue?: boolean; /** Lap/checkpoint race state (`ctx.game.race`) — racers. */ race?: boolean; /** Competitive score tracking (`ctx.game.leaderboard`) — ranked/arcade games. */ leaderboard?: boolean; /** Friends/party/presence/emotes/world-invites (`ctx.game.social`) — social/MMO games. */ social?: boolean; /** Channels + messages (`ctx.game.chat`) — implies `social`; multiplayer/social games. */ chat?: boolean; /** Connected-player set (`ctx.game.players`) — shared-world hosts whose loop ticks every player, not just `ctx.player`. */ players?: boolean; } /** * Declarative start/restart run lifecycle: the state transitions a game's run phase every genre repeats * (title screen → live run → live run → title screen again), expressed as pure functions over one typed * {@link StoreHandle} slot instead of hand-rolled `commands.define("start"/"restart")` glue that re-derives * phase after every mutation. `start`/`restart` receive the store's own value type — the store's `TState`, * never `ctx.game.store.get(key) as T` — and return the next value; the runtime writes it back and derives * {@link GamePhase} from it via `phaseOf` in one place, so every adopting game gets identical, correct * phase-sync for free. * * @capability lifecycle declarative start/restart run flow — the engine owns the command glue and phase sync, the game supplies pure state transitions */ export interface LifecycleConfig { /** The store slot holding this game's run state; `start`/`restart` transform its current value. */ store: StoreHandle; /** Pure transition into a live run. `input` is whatever the `start` command was invoked with (e.g. a chosen difficulty/tier). */ start(state: TState, ctx: GameContext, input?: unknown): TState; /** Pure transition back into a fresh live run — same shape as `start` but never re-shows the menu first. */ restart(state: TState, ctx: GameContext): TState; /** Derive the canonical {@link GamePhase} from the run state. The runtime calls this after every loop hook (`onInit`/`onNewPlayer`/`onTick`) as well as after `start`/`restart`, publishing the result only when it changed — so a game never hand-writes a per-tick `setGamePhase` ternary or its own previous-phase diff. */ phaseOf(state: TState): GamePhase; /** Override the registered command names — default `"start"`/`"restart"` — for games whose input bindings already name them differently. */ commands?: { start?: string; restart?: string; }; } /** * How a game declares its run-phase story to {@link defineGameDefinition}. Every game states this * explicitly so the shell never has to guess — a silent game used to default to `"playing"`, which * painted the touch dock over title/menu/results screens (#1337, Harbor Heat #1329). * * - A {@link LifecycleConfig} — the game moves through menu/playing/paused/ended run states; the * runtime owns the command glue and phase sync. * - `"always-live"` — an explicit, truthful declaration that the game has **no** menu/pause/end * screens and runs live from boot. Runtime behaviour is identical to declaring no lifecycle at all * (no phase sync, no start/restart commands), but the intent is now *stated*, not implied by * silence. Games that instead drive phases by hand call {@link setGamePhase} directly and leave * this unset. */ export type GameLifecycle = LifecycleConfig | "always-live"; /** Context-aware projectile policy; callbacks are not included in saved shot state. */ export interface GameProjectileTravelOptions extends Pick { /** Collision-source limit, default 2048; an oversized source throws rather than dropping targets. */ maxTargets?: number; /** Override the default centerline cover query with a radius-aware collision provider. */ sweep?: (ctx: GameContext, ...args: Parameters>) => ReturnType>; /** Full acceleration override; call ctx.environment.accelerationAt explicitly to compose authored forces. */ acceleration?: (ctx: GameContext, ...args: Parameters>) => ReturnType>; /** Bounded entity IDs for the default moving-target query; omission uses this world's entities. */ targets?(ctx: GameContext): readonly string[]; } /** Fully-resolved game description produced by {@link defineGameDefinition} — assets, scene, and opted-in subsystems. */ export interface GameDefinition { name: string; assets: AssetCatalog; multiplayer: TMultiplayer; /** @deprecated No longer the runtime store — `createGameContext` mints a fresh per-context `EntityStore` so one host can serve many worlds without state bleed (#632). Retained only for API compatibility; read `ctx.scene.entity` instead. */ scene: EntityStore; /** Opt-in `ctx.game.*` subsystems beyond the always-on base; omitted systems are `undefined` on `ctx.game`. */ features?: GameFeatures; /** * Composable game capabilities — the preferred authoring path for runtime behavior. * Each system owns timing, events, and optional save/replication/reset/disposal. * Installing a system with `feature` enables that `ctx.game.*` capability without a redundant flag. * Classic `loop` hooks still run for incremental migration (systems tick first, then `loop.onTick`). */ systems?: readonly SystemDefinition[]; /** * The place this game happens in — preferred shape is `world()` from `@jgengine/core/world/place` * (ground + per-place laws; dressing lives in the editor's scene document). Omit entirely for * pure UI/rules games that are not a spatial place. */ world?: WorldFeature; /** Validated authored scene inputs consumed by the environmental authority. */ authoredDocument?: EditorDocument; /** Bounded projectile travel providers receive their live world; the authoritative clock stays engine-owned. */ projectileTravel?: GameProjectileTravelOptions; /** Optional pursuit target policy; callbacks are world-local injections, never saved behavior data. */ pursuit?: { /** Additional eligibility for nearest, held, explicit and forced targets at scheduled pursuit ticks. * Receives the live world and entity IDs; cannot bypass self/existence/effect acceptance checks. * Omission retains player-role acquisition; explicit targets may still name nonplayers. * A supplied callback must return exactly true to accept; missing, null or asynchronous results deny. * Keep this pair query bounded (for example, a caller-owned faction roster lookup). */ eligible(ctx: GameContext, selfId: string, candidateId: string): boolean; }; /** Game-level default physics laws; a place world's own `physics` resolves over this. */ physics?: PhysicsConfig; /** Simulation clock: real→game time scale, selectable speeds, calendar. Exposed as `ctx.time`; the shell feeds its scaled dt to `loop.onTick`. */ time?: TimeConfig; /** How the simulation is stepped — a fixed `hz` with render interpolation, or `"variable"` (default) once per frame. Exposed as `ctx.sim`. */ simulation?: SimulationConfig; /** Per-action ring-buffer capacity for `ctx.game.feed`. Default 20. */ feed?: GameFeedOptions; inventories?: Record; input?: ActionCodesMap; server?: GameServerConfig; /** Hosted per-player save policy, read only by an authoritative multiplayer host. Solo games leave this unset (nothing persists by default) and use `persist` when they want a save at all. */ save?: SaveConfig; /** The one save knob for solo games: offline whole-world save. **On by default** — an offline game autosaves the entire world to `localStorage` and binds `ctx.game.save` with no config at all; the game only drives save points and restore (`ctx.game.save.load()` at boot). Pass `false` to opt out entirely, or a config object to tune the mode/cadence/target (`storage: "memory"` for in-session-only). Ignored for multiplayer worlds (the host persists via `save`). */ persist?: boolean | PersistConfig; ui?: unknown; loop?: GameLoop; /** * Resolved run lifecycle — see {@link LifecycleConfig}. The config boundary also accepts the * `"always-live"` sentinel ({@link GameLifecycle}); {@link defineGameDefinition} resolves that to * `undefined` here, so a resolved definition only ever carries a real {@link LifecycleConfig} or * nothing. */ lifecycle?: LifecycleConfig; /** * Host-side per-viewer replication policy — private-state and area-of-interest projection over the * wire. Applied on every authoritative host (ws, Convex, loopback); clients need not set it. Unset * replicates the whole world to every client (today's behavior). Changes only what each client sees, * never the simulation, so the game plays identically with or without it. */ replication?: ReplicationPolicy; } /** Input to {@link defineGameDefinition} — a `GameDefinition` with `scene` derived and `assets` optional. */ export type GameDefinitionConfig = Omit, "scene" | "assets" | "lifecycle"> & { assets?: AssetCatalog; /** * The game's run-phase story — a {@link LifecycleConfig} or the `"always-live"` sentinel. Every * game must state one (or drive phases by hand via {@link setGamePhase}); `check-game-shape` * enforces it. See {@link GameLifecycle}. */ lifecycle?: GameLifecycle; }; /** * Host-level constructor for a bare `GameDefinition`: fills in `scene` and default `assets`, validates * `name`, OR-merges `features` from installed systems, and composes `loop` from `systems` + any classic * hooks. Games author through `defineGame` from `@jgengine/shell/defineGame`, which calls this and adds * the presentation layer; call this directly only from headless hosts that mount no shell. */ export declare function defineGameDefinition(config: GameDefinitionConfig): GameDefinition;