From 7002f4d8414e860c950c279c9d144984d4a9d930 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 19 Oct 2026 10:55:11 +0900 Subject: [PATCH 1/6] Ship everything a game's agent needs with the packages, and check simulation lint coverage - Add the `ritmo` agent skill to @typeonce/ritmo (skills/ritmo/SKILL.md). - `ritmo-devtools lint` reports every module the simulation loads that the .oxlintrc.json simulation override does not cover (exit 1); Node >= 22.15. - Ship CHANGELOG.md in every package; move the 0.8/0.9 upgrade notes into the core changelog. MIGRATION.md keeps only the bevy-ts rename, which the published npm deprecation messages link to. - Fix shipped JSDoc (Command example, Random/time as services, inspector semantics), move the remaining CONCEPTS/GAME_API guidance into JSDoc, and prefix every example name. - Root README is the human doc, with agent setup; delete CONCEPTS.md, GAME_API.md, PLATFORM.md (folded into AGENTS.md), the example READMEs, and the superseded recipes example. Co-Authored-By: Claude Opus 5.5 --- .../agent-docs-and-simulation-coverage.md | 17 + AGENTS.md | 10 + CONCEPTS.md | 285 ---------- GAME_API.md | 516 ------------------ MIGRATION.md | 184 +------ PLATFORM.md | 96 ---- README.md | 103 ++-- examples/algorithms/README.md | 22 - examples/concepts.ts | 7 +- examples/pixi/README.md | 294 ---------- examples/recipes.ts | 39 -- packages/browser/CHANGELOG.md | 2 +- packages/browser/README.md | 2 - packages/browser/package.json | 1 + packages/browser/src/InputCapture.ts | 3 + packages/core/CHANGELOG.md | 191 ++++++- packages/core/README.md | 4 +- packages/core/package.json | 2 + packages/core/skills/ritmo/SKILL.md | 52 ++ packages/core/src/Command.ts | 33 +- packages/core/src/Decode.ts | 12 +- packages/core/src/Descriptor.ts | 49 +- packages/core/src/Entity.ts | 2 +- packages/core/src/Inspector.ts | 8 +- packages/core/src/Machine.ts | 2 +- packages/core/src/Query.ts | 18 +- packages/core/src/Relation.ts | 4 +- packages/core/src/Runtime.ts | 22 +- packages/core/src/Schedule.ts | 6 + packages/core/src/Schema.ts | 16 +- packages/core/src/System.ts | 50 +- packages/core/test/Concepts.test.ts | 2 +- packages/core/test/Recipes.test.ts | 10 - packages/devtools/CHANGELOG.md | 4 +- packages/devtools/README.md | 42 +- packages/devtools/package.json | 3 +- packages/devtools/src/cli.ts | 28 +- .../src/internal/simulationCoverage.ts | 136 +++++ .../devtools/test/SimulationCoverage.test.ts | 145 +++++ packages/math/CHANGELOG.md | 2 +- packages/math/README.md | 4 +- packages/math/package.json | 1 + packages/math/src/Random.ts | 2 +- packages/oxlint-plugin/README.md | 4 +- packages/oxlint-plugin/package.json | 1 + packages/pixi/README.md | 2 - packages/pixi/package.json | 1 + packages/render/CHANGELOG.md | 2 +- packages/render/README.md | 2 - packages/render/package.json | 1 + packages/render/src/RenderSync.ts | 3 + scripts/pack-check.ts | 5 +- 52 files changed, 843 insertions(+), 1609 deletions(-) create mode 100644 .changeset/agent-docs-and-simulation-coverage.md delete mode 100644 CONCEPTS.md delete mode 100644 GAME_API.md delete mode 100644 PLATFORM.md delete mode 100644 examples/algorithms/README.md delete mode 100644 examples/pixi/README.md delete mode 100644 examples/recipes.ts create mode 100644 packages/core/skills/ritmo/SKILL.md delete mode 100644 packages/core/test/Recipes.test.ts create mode 100644 packages/devtools/src/internal/simulationCoverage.ts create mode 100644 packages/devtools/test/SimulationCoverage.test.ts diff --git a/.changeset/agent-docs-and-simulation-coverage.md b/.changeset/agent-docs-and-simulation-coverage.md new file mode 100644 index 0000000..7223f55 --- /dev/null +++ b/.changeset/agent-docs-and-simulation-coverage.md @@ -0,0 +1,17 @@ +--- +"@typeonce/ritmo": patch +"@typeonce/ritmo-browser": patch +"@typeonce/ritmo-render": patch +"@typeonce/ritmo-pixi": patch +"@typeonce/ritmo-math": patch +"@typeonce/ritmo-devtools": minor +"@typeonce/oxlint-plugin-ritmo": patch +--- + +**Everything a game's agent needs now ships with the packages, and `ritmo-devtools lint` reports simulation modules the lint rules miss.** + +- **Breaking: `ritmo-devtools lint` fails on an uncovered simulation module.** Every module the entry loads that the `ritmo/keep-simulation-headless` override in `.oxlintrc.json` does not cover is reported as `uncovered-simulation-module`, and the command exits 1. Add the module (or its folder) to the simulation override's `"files"`, or stop importing values from it. Modules loaded only inside `lintTarget` are not checked. Requires Node 22.15 or newer (`engines.node` is now `>=22.15`). +- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: where the docs are, and principles for where a game's code and state belong. Link it into a game with `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .claude/skills/ritmo` (Codex: `.agents/skills/`). +- **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 moved from the repository's `MIGRATION.md` into the `@typeonce/ritmo` changelog. +- **JSDoc corrections.** Examples no longer provide randomness or time as services (use the `Random` resource and `Clock`), the `Command` module example declares its commands, inspector docs state that `added`/`changed` reads report records since the inspector's previous evaluation, and every example uses prefixed names (`"Game/Position"`). Guidance that lived only in the repository's guides (spawn order, `ReadonlyValue` helpers, re-entering a state, `thisTick` and plugin systems, service effects and rollback) is now in the JSDoc. +- Package READMEs link other packages by npm URL, so the links work on npm. diff --git a/AGENTS.md b/AGENTS.md index bd5729b..4675993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,12 +19,22 @@ - Packages never draw. Drawing, particles, and effects stay in games. - Bridge code that hands a renderer or the platform what it needs (nodes, positions, input, timing) and glue that would otherwise repeat in every game belong in the support packages. - Hand the game the data it needs to draw (like `Touch`'s `view()`), never the drawing itself. +- A package earns its place when the host platform or an integration boundary defines what it owns, the repeated logic is infrastructure (not gameplay), and games vary it through typed inputs, descriptors, and callbacks without casts or explicit generics. Feature bundles (a platformer HUD, a browser runtime that wires input, rendering, and state together) stay in games. +- A plugin is a function `(Game, options) => System | Schedule` that returns ordinary systems the game places in its own schedules, like `RenderSync.system(Game, ...)` and `InputCapture.system(Game, ...)`. No plugin objects, registries, or composition steps; a plugin's descriptors are declared by the game or exported as a `Schema.fragment(...)`. +- Generic contracts have generic names (`NodeRegistry`); adapters put the implementation in the name (`PixiNodeRegistry`) and export only themselves. The host provides an adapter with `Game.Runtime.service`; systems declare the generic service. - Core and generic package modules never call a platform API (`performance`, `window`, `document`, timers, file system, network). A platform detail is a generic contract in the owning package with platform implementations injected (like `NodeRegistry` / `PixiNodeRegistry`, `HostClock` / `BrowserHostClock`, `FixedLoop.TickSource` / `BrowserTickSource`). A default may use only what JavaScript itself guarantees in every engine. The compiler enforces this: package configs get only the ES2022 library, so a DOM or Node global does not compile; a host package that needs DOM types lists `"DOM"` in its own `lib`, as `packages/devtools` does for `Monitor`. - Simulation code reads time from `Clock` and randomness from `Random`; host time (`HostClock`) is for hosts, tools, and debug timing, never for systems. - Engines may approximate `Math.sin`, `cos`, `atan2`, `exp`, `log`, `pow`, `hypot`, the other transcendental functions, and `a ** b`, and they differ across browsers and CPUs. Package code and simulation code use `Ops` from `@typeonce/ritmo-math` instead (or `Math.sqrt(x * x + y * y)`). - `pnpm lint` runs the repo's own oxlint plugin (`packages/oxlint-plugin`) over package sources, examples, and scripts (see `.oxlintrc.json`); the simulation rules cover every package source and the examples' simulation files. A rule that should not apply takes `// oxlint-disable-next-line ritmo/ -- `. - A new pattern that games must follow or avoid, and that types and devtools cannot catch, becomes a rule in `packages/oxlint-plugin`, with tests from real code. Its message says what goes wrong and the exact API to use instead. +## Docs +- A game's agent sees only what npm ships: each package's `src` JSDoc, `README.md`, `CHANGELOG.md`, and the skill in [`packages/core/skills/ritmo`](./packages/core/skills/ritmo/SKILL.md). Guidance a game needs goes there, never in a repo-only file. +- The root `README.md` is the only doc for humans. `AGENTS.md`, `ARCHITECTURE.md`, and `RELEASING.md` are for work on this repo. +- Package READMEs link other packages by their npm URL, never by a relative path or into `examples/`, which do not ship. +- A changeset for a breaking change carries its fix (a short diff or the exact replacement), so the shipped `CHANGELOG.md` is the upgrade guide. +- The skill holds only principles that change where code and state go in a game; anything a JSDoc, README, or lint message already says stays out of it. + ## Core Principles - Type safety is the first and absolute priority. - Public APIs must be as strict as possible. diff --git a/CONCEPTS.md b/CONCEPTS.md deleted file mode 100644 index 91e79b0..0000000 --- a/CONCEPTS.md +++ /dev/null @@ -1,285 +0,0 @@ -# ritmo in 5 minutes - -This is the mental model, one concept at a time, for someone who knows ECS. Every snippet comes from [`examples/concepts.ts`](./examples/concepts.ts), a complete program that is typechecked and run by the test suite. - -## 1. Descriptors name your data - -```ts -const Position = Descriptor.ConstructedComponent(Decode.struct({ x: Decode.number, y: Decode.number }))("Concepts/Position") -const DeltaTime = Descriptor.TransientResource()("Concepts/DeltaTime") -const Hit = Descriptor.Event<{ readonly amount: number }>()("Concepts/Hit") -const Log = Descriptor.Service<{ readonly write: (line: string) => void }>()("Concepts/Log") -const { relation: ChildOf } = Descriptor.Hierarchy("Concepts/ChildOf", "Concepts/Children") -``` - -- **Components** are per-entity data. **Resources** are world singletons. **Events** are messages between systems. **Services** are host capabilities (clock, audio, renderer) that live outside the world. **Relations** link entities. -- Identity is `(kind, name)`. A schema rejects two descriptors of one kind with the same name, so prefix names per game or package. -- `Descriptor.Component()` declares a plain component. `Descriptor.ConstructedComponent(validator)` attaches a validating constructor (`{ result: (raw) => Result }`), used for raw input and when loading saves. `@typeonce/ritmo-math` provides `Vector2`, `Size2`, `Aabb` and `Scalar`. `Decode` has ready-made validators (`number`, `integer`, `string`, `boolean`, `literal`, `nullable`, `array`, `struct`, `handle`), `Descriptor.Tag("Player")` declares a marker component, and `Descriptor.fromStandardSchema(schema)` adapts any Standard Schema validator (ArkType, Effect Schema, Zod, Valibot). -- `Descriptor.TransientComponent()` / `TransientResource()` mark runtime-only state that saves skip. -- A component constructor takes `{ immutable: true }` for data that says what an entity is (a kind, a team): queries can only read it, and `commands.insert` replaces it. - -## 2. A schema closes the world - -```ts -const Root = Schema.defineRoot("Concepts") -const Game = Schema.bind(Schema.fragment({ - components: { Position, Velocity, Health, Target }, - resources: { DeltaTime, Score }, - events: { Hit }, - relations: { ChildOf } -}), Root) -``` - -- Everything else is built from `Game`: queries, systems, schedules, state machines and the runtime. -- Anything outside the schema, or from another `Game`, is a compile error. -- Split large games into several `Schema.fragment(...)` values. -- A fragment's `requires: [Schema.requires(Player, Position, Velocity)]` says every entity with `Player` also has `Position` and `Velocity`. Spawns, inserts, and removes that would break it do not compile; nothing is filled in with a default. - -## 3. Systems declare exactly what they touch - -```ts -const Move = Game.System("Concepts/Move", { - queries: { moving: Game.Query({ selection: { position: Game.Query.write(Position), velocity: Game.Query.read(Velocity) } }) }, - resources: { dt: Game.System.readResource(DeltaTime) } -}, ({ queries, resources }) => { - for (const { data } of queries.moving.each()) { /* ... */ } -}) -``` - -- The callback receives only what the spec declares. -- `read` slots are deeply readonly. `write` slots expose `set` / `update`. -- Values from `get()` are `ReadonlyValue`. Type helpers that receive them as `ReadonlyValue` (from `@typeonce/ritmo/Query`), not `T`, so a config resource or component value passes straight in without a cast: `const spawnRate = (tuning: ReadonlyValue) => ...`. -- Queries also take `with`, `without`, `optional(...)`, relation selections, and the change filters described in section 8. -- Query results come back in spawn order. - -## 4. Structural changes are commands - -```ts -const Spawn = Game.System("Concepts/Spawn", { - commands: Game.System.commands(Game.System.spawns(Health, Position)) -}, ({ commands }) => { - const enemy = commands.spawn(Game.Command.spawn([Health, 3], [Position, { x: 10, y: 0 }])) - commands.spawn(Game.Command.relate(Game.Command.spawn([Health, 1]), ChildOf, enemy)) -}) -``` - -- A system has `commands` only when its spec declares them, and only for what it names: `spawns(...)` (every component of the draft, plus what they require), `inserts(...)`, `removes(...)`, `despawns(...)`, `despawnsScope(scope)`, and `relates(...)`. Without the declaration, `commands` is a type naming the fix. -- `despawn` and `remove` take `match.entity` from a query (or the id `spawn` returned in this run), never a raw id. With only an id, resolve it first: `const enemy = queries.enemies.get(id); if (enemy.ok) commands.despawn(enemy.value.entity)`. `despawn` also needs the query to prove one of the `despawns` components. -- `insert` takes `match.entity` too: what the query proves, plus the entries inserted together, must cover what the inserted components require. `remove(entity, A, B)` removes several components at once; a required component goes only together with its requirers, or from a query with `without: [Requirer]`. -- `Game.Command.extend(draft, ...entries)` adds entries to a draft before it is spawned (drafts are checked against `requires` at `commands.spawn`); `commands.insert` changes an entity that already exists. -- Commands are applied only when a schedule reaches `applyDeferred()`. `commands.spawn` returns the entity id immediately. -- `Game.Command.spawn(...)` returns a draft, or a `Result` of one if any entry can fail validation (for example `Game.Command.entryRaw(Position, raw)`). - -## 5. Handles outlive frames; resolving them is explicit - -```ts -const Target = Descriptor.Component<{ readonly enemy: Entity.Handle }>()("Concepts/Target") -// ... -// `enemies` is a query the system declares: queries: { enemies: HealthQuery } -const enemy = queries.enemies.getHandle(data.target.get().enemy) -if (enemy.ok) { /* the entity still exists and has Health */ } -``` - -- A handle is safe to store in components, resources and events, but it doesn't prove the entity is still alive. -- `queries.x.getHandle` returns a `Result`. Every read and write goes through a query the system declares (`get`, `getHandle`, `children`, `descendants` are on the query handle); `lookup` only returns ids along relations. -- A handle with an intent (`Health` here) can only be resolved through a query that proves that component. -- Entity ids and handles are branded numbers. Use them directly as `Map` or `Set` keys. `Decode.handle` validates positive integer handles, and snapshots write numbers. - -### Lookups and reading the world - -When you already have a declared query, use its handle to look up one entity. -`get` returns a `Result`: the entity may be gone or no longer match. Use -`singleOptional` when zero or one match is expected; more than one is still -an explicit error. - -```ts -const found = queries.moving.get(id) -if (found.ok) found.value.data.position.get() - -const only = queries.moving.singleOptional() -if (only.ok && only.value !== undefined) only.value.data.position.get() -``` - -Helpers can take the exact cells of a query without spelling out cell types -or passing a schema parameter: - -```ts -import type { Query } from "@typeonce/ritmo/Query" - -const positionOf = (data: Query.Cells) => data.position.get() -``` - -Access declarations are plain values. Share and spread them into specs; -their query types and requirements are preserved. - -```ts -const ReadMoving = Game.Query({ - selection: { position: Game.Query.read(Position), velocity: Game.Query.read(Velocity) } -}) -const movementAccess = { queries: { moving: ReadMoving } } -const InspectPosition = Game.Inspector("Concepts/InspectPosition", { - ...movementAccess, - machines: { flow: Game.System.machine(Flow) } -}, ({ queries, machines }) => ({ - position: queries.moving.each().map(({ data }) => data.position.get()), - flow: machines.flow.get() -})) -runtime.inspect(InspectPosition) -``` - -An inspector's queries must be read-only; systems can spread writable access too. -Inspectors also read machines, so tests and UI can project the current mode -without changing it or parsing a debug dump. - -## 6. Events are read once per reader - -- A system's events are published when it completes; a failed system publishes nothing. -- Each reader sees the events published since its own previous run, once, in order: from earlier systems in this schedule, and from anything that ran after it last time. -- Events wait for every system that reads them, so a fixed-rate schedule ticked less often than rendering still sees everything. A system skipped by its run conditions discards the events published meanwhile. If a reader stops running long enough to overflow the stream's capacity, it sees `lagged() === true`. -- `runtime.send(Hit, { amount: 1 })` publishes a host event immediately at a fresh tick. It is forbidden during a tick; systems use their declared event writers. Unread host messages survive restore, with each reader's position preserved. Simulation messages are cleared on restore. - -## 7. Expected failures are typed and roll back - -```ts -if (total > 100) { - return Result.failure("ImpossibleDamage" as const) -} -resources.score.update((score) => score + total) -``` - -- A system that can't fail returns nothing. A system that returns `Result.failure(error)` has its ECS writes rolled back and its commands and events dropped. There is no effect type: a system returns nothing or a `Result`. -- `runtime.tick(...)` returns `Result>`, a union built from every system in the schedule. -- Thrown exceptions are treated as bugs: the system's writes are rolled back and the exception is rethrown. - -## 8. Change detection is per system - -```ts -filters: [Game.Query.changed(Position)] -filters: [Game.Query.anyOf(Game.Query.changed(Position), Game.Query.changed(Sprite))] -resources: { score: Game.System.changedResource(Score) } // resources.score.changed() -``` - -- `added(...)` and `changed(...)` match what happened since this system's previous run. Every system sees each change exactly once, independently of other systems. -- Filters listed side by side must all pass; `anyOf(...)` matches when any one of its `added`/`changed` filters does. -- A system's first run sees everything that already exists as added. -- `changedResource(...)` reads a resource and reports `changed()`: whether a committed write left a different value since this system's previous run. Unlike component `changed`, a write of an equal value is not a change. Use it to redraw a HUD label only when its value moves. -- `readRemoved(...)` and `readDespawned()` work the same way. -- Nothing needs a marker. Removal records are kept until every system that reads them has run, so a render schedule ticked after several fixed updates still sees every despawn. - -## 9. State machines model modes - -```ts -const Flow = Game.StateMachine("Concepts/Flow", ["Playing", "Won"]) -// nextMachines: { flow: Game.System.nextState(Flow) } → nextMachines.flow.set("Won") -// when: [Game.Condition.inState(Flow, "Playing")] → gate a system -// Game.Schedule.when([Game.Condition.inState(Flow, "Playing")], Move, Attack) → gate a group -``` - -- Transitions are queued and committed at `applyStateTransitions(bundle)`. That step runs the bundle's `onExit` / `onTransition` / `onEnter` schedules. -- The exit phase of a transition (`onExit`, then `onTransition`) is one transaction: when a system in it fails, the whole phase is undone and the transition stays queued, so the retry starts from the same state. Its commands are applied after the marker, so it cannot contain `applyDeferred()`. -- `Game.Schedule.when(conditions, ...entries)` gates every system in a group (nested schedules included) without repeating `when` on each. Marker steps in the group still run, and the machines the conditions read are requirements of the schedule. -- `Game.Condition.check(name, access, predicate)` gates on anything else with ordinary code. It declares its reads like a system (resources, machines, read-only queries), and they become requirements of what it gates. Reads that consume a per-reader cursor (events, `added`/`changed`, removed/despawned) and services are compile errors, so a check can run before every gated system without side effects. It sees writes made earlier in the same run. - -```ts -const frozen = Game.Condition.check("frozen", { resources: { stop: Game.System.readResource(HitStop) } }, ({ resources }) => resources.stop.get().remaining > 0) -Game.Schedule.when([Game.Condition.inState(Flow, "Playing")], - Game.Schedule.pauseWhen([Game.Condition.not(frozen)], Move, Attack) -) -``` - -A mode that only exists inside another one (a battle phase while playing) is a -sub-state: - -```ts -const Phase = Game.StateMachine("Concepts/Phase", ["Setup", "Battle"], { within: [Flow, "Playing"] }) -// Game.Runtime.machine(Phase, "Setup") → the value it enters each time Flow enters Playing -// when: [Game.Condition.inState(Phase, "Battle")] → implies Flow is Playing -// machines.phase.get() → "Setup" | "Battle" | undefined (undefined while Flow is elsewhere) -// nextMachines.phase.set("Battle") → Result: fails with SubStateInactive while Flow is elsewhere -``` - -- Leaving `Playing` runs the sub-state's `onExit` schedules before the parent's and drops its value; entering runs the parent's `onEnter` first, then the sub-state's. -- Setting the parent to the same state again (`set`, not `setIfChanged`) re-runs its exit and enter, so the sub-state restarts too. -- Use two top-level machines instead when each keeps its value whatever the other does. - -A scope can end with a state change: `Game.EntityScope("Concepts/Run", { despawnOnExit: [Flow, "Playing"] })` despawns its members when `applyStateTransitions` commits leaving `Playing`, with no despawn system. - -`when` discards event messages across mode changes. `pauseWhen` holds them -while a condition is false, up to the normal stream capacity. All `when` -conditions are checked before pause conditions, regardless of nesting order. -Marker steps still run. Debug traces distinguish held pauses from skips. - -For reusable scratch buffers or caches, a system can declare -`local: () => ({ buffer: new Array() })` and receive `local` in its callback. -The value belongs to one runtime, is shared by gated copies, is not rolled -back on failure or saved in snapshots, and is rebuilt after restore. Keep -gameplay state in components and resources. - -The factory allocates fresh state synchronously, without side effects. It runs -when the runtime first encounters the system, even if a gate skips execution. -Pauses and mode changes preserve the value; restore replaces it. Buffers should -be cleared at the start of a run, so a failed run cannot leave partial work for -the next one. Caches must validate all inputs against the current world. Timers, -RNG state, and progress that changes gameplay belong in resources. GPU objects, -listeners, and other values needing cleanup belong in host-managed services. - -A state each entity has on its own (an enemy's attack windup, a door's open/closed) is an entity state, a component, not a machine. Use `Game.StateMachine` for one game-wide mode (menu, playing, paused); use `Descriptor.EntityState` for per-entity states. It is built from a closed set of states and an optional graph of allowed moves: - -```ts -const Attack = Descriptor.EntityState("Attack", ["ready", "windup", "active"] as const, { - transitions: { ready: ["windup"], windup: ["active", "ready"], active: ["ready"] } -}) -Game.Command.spawn([Attack, "ready"]) -// write slot `attack`: -const moved = data.attack.transition("windup", "active") // "ready" -> "active" does not compile -if (!moved.ok) moved.error.actual // some earlier write already changed the state -``` - -- It is an ordinary component: queried, change-detected, rolled back, and validated on restore (unknown states are rejected). -- `transition(from, to)` writes immediately, and only while the state is still `from`. Otherwise it returns `EntityStateMismatch` and writes nothing. The graph lists every state (`[]` for one with no way out), and it is checked at compile time only. `set` still writes any state. -- There are no per-state schedules or filters. Branch on `get()` in the system that owns the component, and emit an event on a transition that other systems react to. - -## 10. Schedules make every boundary visible - -```ts -const update = Game.Schedule(Move, Attack, ApplyHits, Game.Schedule.applyStateTransitions(), Report) -``` - -- Steps run in the order written. Nested schedules are flattened in place. -- Only markers apply queued work: `applyDeferred` (commands) and `applyStateTransitions` (commands, then machine transitions). Reads (change detection, events, relation failures) are per system and need no marker. -- Nothing is flushed when a schedule ends. -- `Game.System.thisTick(Game.System.readEvent(Damage))` (or `readResource` / `changedResource`) declares that a read needs this tick's data: every system writing it must run earlier in the same `runtime.tick(...)` call. The library never reorders; `tick` rejects at compile time a writer after the reader ("ApplyDamage needs Damage from this tick, but its writer UpdateFrostFields runs after it: move ...") and a call whose order is not statically known. A read with no writer in the call compiles (`event-never-written` reports data nobody writes). `tryTick` checks the same at runtime; the `this-tick-order` lint reports it for named schedules. Plugin systems with erased specs (render sync, input capture) are not counted as writers. -- `Game.System.thisTick(Query)` declares that a query needs this tick's command changes. Its writers are systems whose declared commands can change what it matches (`spawns` covering every component it requires, or `inserts` / `removes` of a component it names); each must run earlier with an `applyDeferred()` before the reader ("AimAtNewest needs this tick's commands for queries.enemies, but SpawnWave queues them with no applyDeferred() in between"). `despawns` never count: a despawned entity stays visible until the next `applyDeferred()`. A transition bundle counts at its `applyStateTransitions(...)` step, as if every schedule in it runs. - -## 11. The runtime is driven by your host - -```ts -const runtime = Game.Runtime.make({ - services: Game.Runtime.services(Game.Runtime.service(Log, { write: console.log })), - resources: { DeltaTime: 1 / 60, Score: 0 }, - machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Playing")) -}).value - -runtime.tick(setup) -runtime.tick(update) // once per frame, e.g. from FixedLoop -runtime.inspect(SomeInspector) // read-only projection for UI, tests, debugging -runtime.restore(JSON.parse(JSON.stringify(runtime.snapshot()))) -``` - -- Ticking a schedule whose systems need a resource, service or machine the runtime wasn't given is a compile error. `tryTick` is the checked-at-runtime path for tools (devtools sessions) that hold schedules whose types were erased; games use `tick`. -- Resources with constructed descriptors take raw input, and `make` then returns a `Result`. -- Snapshots are plain data. `restore` validates untrusted input and leaves the world unchanged on failure. -- `snapshot` and `restore` only compile when every component and resource is transient or constructed with a validator that accepts untrusted input (`result(raw: unknown)`, or a `decode` such as `Vector2.decode`), so nothing unvalidated can load. Stored handles are validated with `Decode.handle(Root, Intent)`. - -## Packages - -| Package | What it owns | -|---|---| -| `@typeonce/ritmo` | Schema, systems, schedules, runtime, snapshots, `FixedLoop` timing | -| `@typeonce/ritmo-math` | Validated `Scalar`, `Vector2`, `Size2`, `Aabb`, `InputAxis`; hot-path `Vec2`, `Box2`, `Collide2`, `SpatialGrid`, `FlowField`, `Random`, and `Ops` (math that returns the same bits on every engine) | -| `@typeonce/ritmo-browser` | `BrowserTickSource` animation frames, `Keyboard` action input, `InputCapture` into resources | -| `@typeonce/ritmo-render` | `NodeRegistry` and `RenderSync` (with `redrawOn`) for mirroring entities into any renderer | -| `@typeonce/ritmo-pixi` | `PixiNodeRegistry.make` for Pixi containers | -| `@typeonce/oxlint-plugin-ritmo` | Oxlint rules (`ritmo/*`) for what types and devtools cannot see: wall clocks, `Math.random`, engine-dependent math, and the page in simulation code; unsaveable data; dropped runtime results | - -Next: [GAME_API.md](./GAME_API.md) for a larger walkthrough, [ARCHITECTURE.md](./ARCHITECTURE.md) for how the types and storage work. diff --git a/GAME_API.md b/GAME_API.md deleted file mode 100644 index 857ea90..0000000 --- a/GAME_API.md +++ /dev/null @@ -1,516 +0,0 @@ -# Building a game with `ritmo` - -The library now has a small, renderer-independent gameplay core. A game is a -closed schema, systems with declared capabilities, explicit schedules, and one -runtime. Browser timing and rendering stay outside the ECS. - -## 1. Define the world - -```ts -import { Descriptor, Result, Schema } from "@typeonce/ritmo" - -const Position = Descriptor.Component<{ x: number; y: number }>()("Game/Position") -const Velocity = Descriptor.Component<{ x: number; y: number }>()("Game/Velocity") -const Health = Descriptor.Component()("Game/Health") -const DeltaTime = Descriptor.Resource()("Game/DeltaTime") -const Damage = Descriptor.Event<{ target: number; amount: number }>()("Game/Damage") - -const Game = Schema.bind(Schema.fragment({ - components: { Position, Velocity, Health }, - resources: { DeltaTime }, - events: { Damage } -})) -``` - -The bound `Game` value is the authoring boundary. Queries, systems, schedules, -entity scopes, inspectors, and runtimes made from a different root do not fit. - -### Components that always come together - -When one component only makes sense with others, say so in the schema instead -of keeping a list of "parts" next to each draft: - -```ts -const Player = Descriptor.Tag("Game/Player") -const EnemyKind = Descriptor.Component<"grunt" | "boss">()("Game/EnemyKind", { immutable: true }) - -const Game = Schema.bind(Schema.fragment({ - components: { Position, Velocity, Health, Player, EnemyKind }, - requires: [ - Schema.requires(Player, Position, Velocity, Health), - Schema.requires(EnemyKind, Position, Health) - ] -})) - -const SpawnPlayer = Game.System("Game/SpawnPlayer", { - // Also allows Position, Velocity, and Health. - commands: Game.System.commands(Game.System.spawns(Player)) -}, ({ commands }) => { - commands.spawn(Game.Command.spawn([Player, {}], [Position, { x: 0, y: 0 }])) - // error: Game/Player requires Game/Velocity: add the missing entries to the draft -}) -``` - -The draft is checked where it is committed (`commands.spawn` / `spawnIn`), so a -draft can be built in steps with `Game.Command.extend`. `commands.insert` checks -what the target's query proves plus the entries inserted with it, and -`commands.remove(match.entity, ...components)` refuses to remove a required -component while the entity may keep its requirer: remove both in one call, or -match the entity with a query that has `without: [Requirer]`. Requirements may -name components of other fragments of the same `Schema.bind`. - -Types cannot see everything (another system's command applied first in the -same `applyDeferred()`, erased types), so the runtime checks again when -commands are applied: a command that would break a requirement is skipped, -traced, and reported by the `requirement-violated` lint. `restore` rejects a -save whose entity misses a required component. - -`immutable: true` marks data that says what an entity is. Queries can only -read it (`Game.Query.write(EnemyKind)` does not compile); change it by -replacing it with `commands.insert(match.entity, [EnemyKind, "boss"])`, which -`Game.Query.changed(EnemyKind)` sees after the next `applyDeferred()`. - -## 2. Write systems with exact capabilities - -```ts -const Moving = Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.read(Velocity) - } -}) - -const Move = Game.System( - "Game/Move", - { - queries: { moving: Moving }, - resources: { dt: Game.System.readResource(DeltaTime) } - }, - ({ queries, resources }) => { - const dt = resources.dt.get() - for (const { data } of queries.moving.each()) { - const position = data.position.get() - const velocity = data.velocity.get() - data.position.set({ - x: position.x + velocity.x * dt, - y: position.y + velocity.y * dt - }) - } - } -) -``` - -The callback cannot access undeclared world data. A write query exposes writable -cells; a read query does not. Values returned by `get()` are deeply readonly; -changes go through `set`, `update`, or a validated write helper. Type helper -functions that take such values as `ReadonlyValue` (from -`@typeonce/ritmo/Query`) so they accept `get()` results without casts. - -Work that only matters when something moved reads the change explicitly -instead of keeping a "last value" by hand: - -```ts -const SyncHud = Game.System("Game/SyncHud", { - resources: { score: Game.System.changedResource(Score) }, - services: { hud: Game.System.service(Hud) } -}, ({ resources, services }) => { - if (!resources.score.changed()) return - services.hud.score.textContent = `Score ${resources.score.get()}` -}) - -const Redraw = Game.Query({ - selection: { position: Game.Query.read(Position), sprite: Game.Query.read(Sprite) }, - filters: [Game.Query.anyOf(Game.Query.changed(Position), Game.Query.changed(Sprite))] -}) -``` - -`changed()` is true when a committed write left a different value since this -system's previous run (and on its first run). `anyOf` matches an entity once -when any of its `added`/`changed` filters passes; filters listed side by side -must all pass. - -## 3. Keep expected failures in the type - -```ts -const SpendHealth = Game.System( - "Game/SpendHealth", - { - queries: { - target: Game.Query({ - selection: { health: Game.Query.write(Health) } - }) - } - }, - ({ queries }) => { - const target = queries.target.single() - if (!target.ok) { - return Result.failure("TargetMissing" as const) - } - - const health = target.value.data.health.get() - if (health <= 0) { - return Result.failure("AlreadyDead" as const) - } - - target.value.data.health.set(health - 1) - } -) - -const gameplay = Game.Schedule(Move, SpendHealth) -const runtime = Game.Runtime.make({ - services: Game.Runtime.services(), - resources: { DeltaTime: 1 / 60 } -}).value - -const update = runtime.tick(gameplay) -if (!update.ok) { - // update.error is the exact named-system failure union: - // SystemFailure<"Game/SpendHealth", "TargetMissing" | "AlreadyDead"> - console.error(update.error.system, update.error.error) -} -``` - -Component, resource, event, queued-machine, and deferred-command writes -from the failing system are rolled back or discarded. Earlier successful -systems remain committed. External service effects, such as network or audio -calls, cannot be rolled back by the ECS. - -Thrown exceptions remain defects and are rethrown after ECS writes from that -system are rolled back. `Result.failure` is for expected game outcomes. - -## 4. Own scene entities as a group - -Marker components are useful gameplay data, but they should not be required -only to delete a level. Entity scopes model ownership directly. - -```ts -const Level = Game.EntityScope("Game/Level") - -const LoadLevel = Game.System("Game/LoadLevel", { - commands: Game.System.commands(Game.System.spawns(Position, Health)) -}, ({ commands }) => - { - commands.spawnIn(Level, Game.Command.spawn( - Game.Command.entry(Position, { x: 10, y: 20 }), - Game.Command.entry(Health, 3) - )) - } -) - -const UnloadLevel = Game.System("Game/UnloadLevel", { - commands: Game.System.commands(Game.System.despawnsScope(Level)) -}, ({ commands }) => - { commands.despawnScope(Level) } -) -``` - -Persistent entities can still use `commands.spawn(...)`. Scope cleanup uses the -normal deferred-command boundary, so removal readers and renderer cleanup -systems see the same despawns as any other entity removal. - -When the group lives exactly as long as a state, name the state change on the -scope instead of writing an unload system: - -```ts -const Flow = Game.StateMachine("Game/Flow", ["Menu", "Playing", "GameOver"]) -const Run = Game.EntityScope("Game/Run", { despawnOnExit: [Flow, "Playing"] }) - -// No despawn system, no despawnsScope permission, no marker between despawn and respawn. -const transitions = Game.Schedule.transitions( - Game.Schedule.onEnter(Flow, "Playing", [SpawnRun]) -) -``` - -`applyStateTransitions(...)` despawns every member right after it commits the -change: after the `onExit` schedules (which still see the members) and before -the `onEnter` schedules (whose spawns survive). `despawnOnEnter: [Flow, "Playing"]` -wipes on entering instead, for a restart that should keep the last round on -screen until the next one starts. A state or machine that does not belong to -the game fails to compile, and the `scope-trigger-never-applied` lint names a -trigger that can never fire. Keep `commands.despawnScope(...)` for wipes not -tied to a state change. - -## 5. Read the world without making a diagnostic system - -Inspectors are reusable read-only projections for tests, saves, debugging, UI -bridges, and host code. - -```ts -const WorldSummary = Game.Inspector( - "Game/WorldSummary", - { - queries: { - actors: Game.Query({ - selection: { - position: Game.Query.read(Position), - health: Game.Query.read(Health) - } - }) - } - }, - ({ queries }) => queries.actors.each().map(({ entity, data }) => ({ - id: entity.id, - position: data.position.get(), - health: data.health.get() - })) -) - -const summary = runtime.inspect(WorldSummary) -``` - -An inspector cannot declare write queries, write resources, commands, event -writers, or state-transition writers. Runtime provisioning is checked at the -call. Like a system, an inspector's `added`/`changed` filters and event -reads report what was published since its previous evaluation. - -## Save and load - -```ts -localStorage.setItem("save", JSON.stringify(runtime.snapshot())) - -const loaded = runtime.restore(JSON.parse(localStorage.getItem("save") ?? "null")) -if (!loaded.ok) { - // loaded.error: InvalidSnapshot | UnknownComponent | InvalidComponent | ... - console.error(loaded.error) -} -``` - -A snapshot is plain data keyed by descriptor, relation, and machine names. -`restore` takes `unknown`, validates it against the schema (every component -and resource value runs through its descriptor's constructor), and leaves the -world untouched on failure. - -Both methods only exist when every component and resource is either -transient (never saved) or constructed with a validator that accepts -untrusted input: a `result(raw: unknown)`, or a `decode(raw: unknown)` next to -a typed `result` (every validated value module in `@typeonce/ritmo-math` exports one). Otherwise calling -them fails to compile, and the error lists each descriptor to fix: - -```ts -const Position = Descriptor.ConstructedComponent(Vector2)("Position") -const Stats = Descriptor.ConstructedComponent(Decode.struct({ level: Decode.integer, name: Decode.string }))("Stats") -const Target = Descriptor.ConstructedComponent(Decode.struct({ enemy: Decode.handle(Root, Health) }))("Target") -const Player = Descriptor.Tag("Player") -const Label = Descriptor.ConstructedComponent(Descriptor.fromStandardSchema(type("string")))("Label") -const Sprite = Descriptor.TransientComponent<{ frame: number }>()("Sprite") // rebuilt after load -``` - -Restored entities come back without transient components, and transient -resources keep their current values. Entity ids are kept, so stored handles still resolve. A restore reads -as despawns and spawns to change detection, so renderer sync rebuilds itself. - -## Debug a run - -A runtime made with `debug: { hostClock }` carries a read-only `debug` handle: -`describe()` for schedules and declared access, `dump()` for the current -world, `observe(listener)` for a trace of every system run, applied command, -and transition, and `streams()` for event retention. Runtimes made without it -have no handle. The host clock times traces: `HostClock.test()` (every timing -0) in tests and headless scripts, `BrowserHostClock.make(window)` in the -browser, `HostClock.standard` in Node tools that want real timings. - -`@typeonce/ritmo-devtools` wraps the handle in a session that runs schedules headless -and answers questions as text: - -```ts -const hostClock = HostClock.test() -const runtime = Game.Runtime.make({ services, resources, debug: { hostClock } }).value -const session = Session.make(runtime, { hostClock, schedules: { setup, update }, invariants: [HealthNeverNegative] }) - -session.run("setup") -console.log(session.run("update", { frames: 300 })) // stops at the first violated invariant -console.log(session.why(12, Health)) // latest changes and the systems that made them -console.log(session.journal({ frames: [180, 185], entity: 12 })) -``` - -Runs also warn about what types cannot rule out: NaN or infinite numbers -written anywhere, and component counts that keep growing (entities never -despawned). `describe()` notes systems that read a value before the schedule -writes it. - -Keep the simulation schedules free of renderer services so they run in Node, -and feed input through `Keyboard.scripted(...)`. See -[`packages/devtools/README.md`](./packages/devtools/README.md). - -## 6. Drive fixed updates from any renderer - -`FixedLoop` in `@typeonce/ritmo` supplies timing policy without owning the -ECS, the renderer, or the platform. Frames come from a `TickSource` the host -passes in; `@typeonce/ritmo-browser` provides one over `requestAnimationFrame`. - -```ts -import { Clock, FixedLoop } from "@typeonce/ritmo" -import { BrowserTickSource } from "@typeonce/ritmo-browser" - -const source = BrowserTickSource.make(window) -const loop = FixedLoop.start({ - source, - step: Clock.hz(60), - maxFrameSeconds: 0.1, - maxStepsPerFrame: 5, - update: (stepSeconds) => { - // A capture system can copy this host value into DeltaTime first. - frameTime.deltaSeconds = stepSeconds - return runtime.tick(gameplay) - }, - render: ({ alpha, droppedSeconds }) => { - renderer.render({ alpha }) - if (droppedSeconds > 0) { - metrics.recordDroppedTime(droppedSeconds) - } - }, - onFailure: (failure) => { - if (failure.kind === "UpdateFailure") { - showGameError(failure.error) - } - } -}) - -if (!loop.ok) { - throw new Error(`Invalid timing option: ${loop.error.field}`) -} - -// Later: -loop.value.stop() -``` - -Pixi, Three, Canvas, native shells, tests, and server simulations can provide -another `TickSource`, usually by wrapping their own ticker with -`FixedLoop.source(...)`. The gameplay schedules do not change. - -Keyboard input is exposed the same way: bind named actions once, and let -`InputCapture` copy one snapshot per update into a resource that gameplay -systems read like any other world data. - -```ts -import { InputCapture, Keyboard } from "@typeonce/ritmo-browser" - -const bindings = { left: ["ArrowLeft", "a"], right: ["ArrowRight", "d"], jump: [" ", "ArrowUp", "w"] } as const - -const KeyboardInput = Descriptor.Service>()("Game/Keyboard") -const Input = Descriptor.TransientResource>()("Game/Input") - -const CaptureInput = InputCapture.system(Game, { name: "Game/CaptureInput", source: KeyboardInput, resource: Input }) - -const Jump = Game.System("Game/Jump", { resources: { input: Game.System.readResource(Input) } }, ({ resources }) => { - const input = resources.input.get() - input.jump.pressed // true once per capture, even for taps shorter than a frame - input.left.held -}) - -const keyboard = Keyboard.actions(window, bindings) -const runtime = Game.Runtime.make({ - services: Game.Runtime.services(Game.Runtime.service(KeyboardInput, keyboard)), - resources: { Input: Keyboard.idle(bindings) } -}).value -const update = Game.Schedule(CaptureInput, Jump) - -keyboard.dispose() // on teardown -``` - -The source can be any service with `snapshot()`, so a game that merges -keyboard, pointer, and gamepad input captures its own adapter the same way. -The resource's value type must match the snapshot type exactly. - -## Recipes: simulation time and seeded random - -Time is world data. Advance it from a fixed-step system so headless tests -and snapshot replay use the same clock as gameplay. The host supplies the -fixed step size; it does not capture elapsed time from a wall clock. - -```ts -const Time = Descriptor.ConstructedResource(Decode.struct({ - step: Decode.integer, elapsed: Decode.number, dt: Decode.number -}))("Recipes/Time") - -const AdvanceTime = Game.System("Recipes/AdvanceTime", { - resources: { time: Game.System.writeResource(Time) } -}, ({ resources }) => { - resources.time.update((time) => ({ - ...time, step: time.step + 1, elapsed: time.elapsed + time.dt - })) -}) -// Initialize Time with { step: 0, elapsed: 0, dt: 1 / 60 }. -// Place AdvanceTime before the systems that read it. -``` - -A seeded generator needs only a number resource and a pure `next` function. -Saving the resource saves the generator's position. A failed system rolls -back the updated seed with its other resource writes. - -```ts -const Random = Descriptor.ConstructedResource(Decode.integer)("Recipes/Random") -const next = (seed: number): { readonly seed: number; readonly value: number } => { - const state = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - return { seed: state, value: state / 4294967296 } -} - -const Roll = Game.System("Recipes/Roll", { - resources: { random: Game.System.writeResource(Random) } -}, ({ resources }) => { - const rolled = next(resources.random.get()) - resources.random.set(rolled.seed) - // Use rolled.value for this system's random decision. -}) -``` - -Include both resources in the bound schema. The complete, typechecked -example is [examples/recipes.ts](./examples/recipes.ts); its test restores a -snapshot and verifies that the next step produces the same time and seed. - -## 7. Mirror entities into a renderer - -`@typeonce/ritmo-render` owns the node bookkeeping for any renderer (Pixi, -Three.js, the DOM); what a node looks like, and how it joins and leaves the -scene, stays yours. - -```ts -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render" - -const RenderNodes = Descriptor.Service>()("Game/RenderNodes") - -const render = RenderSync.system(Game, { - name: "Game/Render", - renderable: Renderable, - transform: Position, - registry: RenderNodes, - create: ({ renderable }) => makeNode(renderable), - apply: (node, { transform }) => node.position.set(transform.x, 0, transform.y) -}) - -const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(RenderNodes, NodeRegistry.make({ - attach: (node) => scene.add(node), - detach: (node) => scene.remove(node) - })) - ) -}).value - -const update = Game.Schedule(Gameplay, Game.Schedule.applyDeferred(), render) -``` - -For Pixi, import `PixiNodeRegistry` from `@typeonce/ritmo-pixi` and provide -`PixiNodeRegistry.make(actorLayer)` as the same registry service. Nodes are -attached to the container and destroyed with their children when removed. -Keep `NodeRegistry` and `RenderSync` imports in `@typeonce/ritmo-render`. - -The registry service is a normal requirement: ticking the render system on a -runtime that does not provide it is a compile error. `select`, `resources`, -and `services` pass extra read-only data to the callbacks, and `redrawOn` -lists components whose changes re-run `apply` (animation frames, tints, -interpolation). - -With a fixed-step loop, draw movement between steps: copy `Position` into a -`PrevPosition` component at the start of each step, leave out `transform` -above, and add `RenderSync.interpolate(Game, { name, registry: RenderNodes, -previous: PrevPosition, current: Position, clock: RenderClock, place })`, -where `RenderClock` is a service holding `FixedLoop`'s render `alpha`. - -## What remains adapter code - -The core deliberately does not choose an asset loader, renderer, audio engine, -physics engine, networking stack, or save format. Those integrations should be -small packages built from services, systems, lifecycle readers, and inspectors. -The next renderer package should be based on at least two real games so its API -captures a repeated pattern instead of one demo's object model. diff --git a/MIGRATION.md b/MIGRATION.md index 97626d0..e8496b4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,187 +1,10 @@ # Migration guide -## Upgrading to 0.9.0 - -0.9.0 rejects several calls that compiled before but were always wrong. -Fix each error where it is reported; the messages name the fix. - -- **`Runtime.make` always returns a `Result`.** When nothing provided can - fail, its type is `Success`: read `.value`. When a constructed resource - may be provided, check it as before. - - ```diff - -const runtime = Game.Runtime.make({ services, resources: { Score: 0 } }) - +const runtime = Game.Runtime.make({ services, resources: { Score: 0 } }).value - ``` - -- **A query match is valid only during its run.** Keeping a match in - `local` and reading or writing it later throws once its entity was - despawned or lost the component. Keep the entity id or a handle, and - resolve it with `queries.x.get(id)` / `getHandle(handle)`. -- **A look-alike descriptor must behave like the registered one.** A - descriptor built elsewhere with the same kind and name but different - behaviour (an entity state, a constructor, `immutable`, transient) throws - when its query first runs; use the descriptor the schema registers. -- **New lint `next-state-replaced`.** On a debug runtime, two systems - queuing different next states for one machine before the same marker are - reported. Decide in one system, or check `nextMachines.x.getPending()` - before setting. - -- **Draft relations need `relates`.** A system that spawns a draft built - with `Game.Command.relate(...)` declares the relation: - - ```diff - -commands: Game.System.commands(Game.System.spawns(Errand)) - +commands: Game.System.commands(Game.System.spawns(Errand), Game.System.relates(WalkerOf)) - ``` - - The `relation-failure-never-read` lint then sees these relations: read - their failures (`Game.System.readRelationFailures(WalkerOf)`) and handle - them, for example by despawning a child whose parent was gone when it - spawned. - -- **A command target picked from several queries must be valid for each.** - `commands.despawn(cond ? a.entity : b.entity)` needs both queries to prove - a `despawns` component; resolve the entity through one query, or branch - and call the command in each branch. A raw id is never a target, not even - as one side of a conditional. -- **Conditional and spread entries count as maybe present.** A requirer a - draft may hold needs its requirements surely held: - `spawn(Game.Command.spawn(...parts))` with a widened array fails when a - part requires another; list the required components as fixed entries. -- **`Runtime.make` names unknown resource keys.** Fix the spelling, or - remove the key. -- **Queries that can never match fail to compile.** Remove the component - from `without` or from the required side. -- **A bundle's transition schedules require their machine.** Provide it with - `Game.Runtime.machine(...)`, or drop the schedule. -- **Registries take their own kind.** Move a resource out of `components`, - and so on. -- **Type-level:** `EntityRef` / `EntityMut` `Proves` and `Lacks` parameters - are contravariant and default to `never`; `EntityDraft` has a fourth - parameter (`Relates`). Code that names these types with explicit - arguments updates them; inferred code is unchanged. -- **Removed duplicates.** Use `Descriptor.Hierarchy` / `Descriptor.Relation` - instead of `Relation.Hierarchy` / `Relation.Relation`, and - `Schema.fragment({})` instead of `Schema.empty()`. -- **Permissions name at least one entry**, and a system declares one slot - per resource (a write or changed view reads too). -- **Optional slots read with `value()`**: `cell.value()` replaces - `cell.present ? cell.get() : undefined`. -- **A `SpawnedId` is valid only in the run that spawned it.** A `despawn` - or `remove` given one kept from an earlier run is skipped and reported - by the `stale-target` lint. Keep the entity id or a handle and resolve it - through a query (`queries.x.get(id)`), then pass `match.entity`. -- **Descriptor values cannot be `any` or `never`.** Give the value a type; - use `unknown` for a value no system reads, and `Descriptor.Tag` for a - marker. -- **A saved component cannot require a transient one.** Make the requirer - transient too, or give the required component a constructor. -- **Filtered queries have no lookups.** Resolve entities through a second - query without `added` / `changed` filters: - - ```diff - -Game.System("Follow", { queries: { moved: Moved } }, ({ queries }) => { - - const target = queries.moved.getHandle(handle) - +Game.System("Follow", { queries: { moved: Moved, targets: Targets } }, ({ queries }) => { - + const target = queries.targets.getHandle(handle) - ``` - -- **`onExit` / `onTransition` schedules cannot contain `applyDeferred()`.** - Their commands are applied at the next `applyDeferred()` after the - marker; a failure in the exit phase now undoes the whole phase. -- **Provide each service and machine once** in `Runtime.services(...)` / - `Runtime.machines(...)`. -- **Snapshots carry `gameVersion`.** Saves without it restore as version - 0. Code that builds a `Snapshot.WorldSnapshot` by hand adds - `gameVersion: 0`. To change a game's save format, pass - `saves: Snapshot.migrations(...)` to `Game.Runtime.make` instead of editing - raw saves before `restore`. -- **Keep random state with `Random.draw`.** Declare the resource with - `Descriptor.ConstructedResource(Random)`, start it with `Random.seed(n)` - (the runtime is then made as a `Result`), and draw with - `Random.draw(resources.rng)` instead of `Random.make(state)` plus a `set`. - `Random.integer` / `range` now throw on bounds with nothing to draw, and - `SpatialGrid` on non-finite input. -- **New lints `stale-target` and `stream-loss-unread`** on debug runtimes. - For the second, check `lagged()` where a missed entry matters and rebuild - from queries when it is true. -- **Behaviour:** `Collide2` now stops a circle that starts exactly touching - a wall and moves into it. A game that fingerprints its simulation - re-records the fingerprints with the upgrade. - -- **`FixedLoop` moved to `@typeonce/ritmo`.** Import it from core. The - browser's animation-frame source is now `BrowserTickSource.make(window)`; - a source wrapped with `FixedLoop.source(...)` is unchanged. - - ```diff - -import { FixedLoop } from "@typeonce/ritmo-browser" - -const source = FixedLoop.animationFrames(window) - +import { FixedLoop } from "@typeonce/ritmo" - +import { BrowserTickSource } from "@typeonce/ritmo-browser" - +const source = BrowserTickSource.make(window) - ``` - -## Upgrading to 0.8.0 - -0.8.0 adds component requirements (`Schema.requires`) and changes how -`commands.insert` and `commands.remove` take their target. - -- **`remove` takes a query match, not a raw id.** Pass `match.entity` (or the - id `commands.spawn` returned in the same run). With only a stored id, - resolve it first. - - ```diff - -for (const match of queries.marked.each()) commands.remove(match.entity.id, Marker) - +for (const match of queries.marked.each()) commands.remove(match.entity, Marker) - - -commands.remove(storedId, Sprite) - +const found = queries.sprites.get(storedId) - +if (found.ok) commands.remove(found.value.entity, Sprite) - ``` - - A raw id fails with an error that first prints the id's type (the whole - schema), then the fix: `remove needs an entity from a query (pass - match.entity) or the id spawn returned in this run, not a raw id`. - -- **`remove` takes several components.** Removing a component together with - the components that require it must happen in one call: - `commands.remove(match.entity, Player, Velocity)`. -- **`insert` takes `match.entity` too.** A raw id still works for components - that require nothing; a component with requirements needs a target whose - query proves them, or the requirements inserted in the same call. -- **A query's `without` proves absence.** `remove(match.entity, Velocity)` - compiles when every component that requires `Velocity` is excluded by the - query (`without: [Player]`) or removed in the same call. -- **Hand-kept part lists can go.** Declare what always comes together in the - schema, and give `spawns` the main component: - - ```diff - -export const PlayerParts = [Position, Velocity, Collider, Player] as const - -commands: Game.System.commands(Game.System.spawns(...PlayerParts)) - +Schema.fragment({ components: { ... }, requires: [Schema.requires(Player, Position, Velocity, Collider)] }) - +commands: Game.System.commands(Game.System.spawns(Player)) - ``` - - - Put a requirement on the component that needs it, not on widely shared - ones. If many components require `Position` or `Renderable`, every - `remove` of those needs a query with `without` for each requirer. - - Only `spawns(...)` expands requirements. `inserts(...)`, `removes(...)`, - and `remove(...)` do not, so a list such as "every part of a body" stays a - plain array: `removes(...BodyParts)` and `remove(match.entity, ...BodyParts)`. - - Components a draft may hold that no requirement covers (optional parts) - are still listed in `spawns(...)` next to the main component. - -- **New optional descriptor option.** `{ immutable: true }` on a component - makes `Game.Query.write` of it a compile error; replace it with `insert`. -- **Debug output.** `describe()` component entries gain `immutable` and - `requires`; a new effect kind `requirementViolated` and lint - `requirement-violated` report commands skipped at apply time. Replay schema - hashes are unchanged. +Upgrade notes for each release are in each package's `CHANGELOG.md`, shipped with the package; breaking entries carry their fix. ## Rename from bevy-ts -Ritmo is the new name for bevy-ts. All six packages retain the `@typeonce` scope +Ritmo is the new name for bevy-ts. All packages retain the `@typeonce` scope and share one version. | Previous package | Replacement | @@ -210,7 +33,4 @@ old and new packages use different type identity markers. This guide covers the name change. Review the changelog for other API changes between your installed version and the Ritmo version you select. -The generated documentation site is retired. Use the [root README](./README.md), -[concepts](./CONCEPTS.md), [game API guide](./GAME_API.md), -[Pixi walkthrough](./examples/pixi/README.md), package READMEs, and source JSDoc. Existing published bevy-ts versions remain available for pinned installs. diff --git a/PLATFORM.md b/PLATFORM.md deleted file mode 100644 index bdd44e7..0000000 --- a/PLATFORM.md +++ /dev/null @@ -1,96 +0,0 @@ -# Platform Packages - -`ritmo` core stays platform-agnostic and renderer-agnostic. - -Extra packages are allowed when they stay platform-specific and encapsulate a stable integration boundary that user code would otherwise have to rewrite in every project. These packages must be composable lego blocks: they own lifecycle and plumbing, while callers keep control over schema, resources, services, schedules, and game semantics. - -## The plugin rule - -A plugin is a function `(Game, options) => System | Schedule`. It takes the game's bound `Game` and typed options (descriptors, services, conditions) and returns ordinary systems or schedules that the game places in its own schedules. `RenderSync.system(Game, ...)` and `InputCapture.system(Game, ...)` are the pattern. There is no plugin object, registry, or composition step: descriptors a plugin needs are declared by the game (or exported as a `Schema.fragment(...)` the game lists in `Schema.bind(...)`), and the returned systems carry their requirements like any other, so `runtime.tick(...)` checks them. - -## Good Packages - -### `@typeonce/ritmo-browser` - -Own browser-specific infrastructure: - -- browser tick sources for the core `FixedLoop` (`BrowserTickSource.make(window)`) and the browser host clock -- mount lifecycle and teardown helpers -- viewport and frame-time capture -- browser input source helpers that produce caller-defined snapshots - -This package must not own gameplay resources or feature flow. - -### `@typeonce/ritmo-render` - -Own renderer-agnostic render infrastructure: - -- entity-keyed render node registry lifecycle (create once, attach, detach, destroy, teardown), with attach and detach supplied by the caller -- the generic render sync systems: create, re-apply, and destroy nodes in step with the world, and interpolate fixed-step movement between steps - -This package imports no renderer. It must not own what a node looks like, HUD semantics, or camera policy. - -### `@typeonce/ritmo-pixi` - -Own Pixi-specific infrastructure implementing the contracts in `@typeonce/ritmo-render`: - -- registries whose nodes are children of a Pixi container and are destroyed with their children (`PixiNodeRegistry.make`) -- the Pixi-shaped surfaces those need (`ContainerLike`, `Destroyable`), without importing `pixi.js` - -This package must not own sprite conventions, HUD semantics, actor kinds, or camera policy. - -### Adapter names and service provision - -Generic contracts have generic names, such as `NodeRegistry`. Adapter modules -include their implementation in the name, such as `PixiNodeRegistry`, so both -can be imported together without aliases. Adapter packages export their own -implementations; generic APIs are imported from the package that owns them. - -This follows Effect's separation of `FileSystem` from `NodeFileSystem`, and -`KeyValueStore` from `BrowserKeyValueStore`. The host constructs an adapter and -provides it through `Game.Runtime.service`; systems declare the generic service -descriptor. Ritmo's synchronous services use that existing provision API. - -```ts -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" - -const Sprites = Descriptor.Service>()("Game/Sprites") -const services = Game.Runtime.services( - Game.Runtime.service(Sprites, PixiNodeRegistry.make(actorLayer)) -) -// RenderSync.system(Game, { ..., registry: Sprites }) uses the generic contract. -``` - -### `@typeonce/ritmo-devtools` - -Own debugging infrastructure over the core `Debug` handle: - -- headless debug sessions that drive named schedules -- bounded trace history and text rendering for terminals and agents -- invariants checked between frames - -This package reads the world; it must not mutate it or require games to change their runtime shape beyond `debug: { hostClock }`. - -## Bad Packages - -These do not belong as public platform packages: - -- `@typeonce/ritmo-platformer-hud` -- `@typeonce/ritmo-browser-runtime` -- `@typeonce/ritmo-browser-rendered` -- `@typeonce/ritmo-pixi-top-down-player` -- any package that bundles input, rendering, state, and gameplay into one opinionated runtime - -Those abstractions are feature-specific, not platform-specific. They stop being composable as soon as a game has slightly different behavior. - -## Inclusion Rule - -A package is a good fit only if: - -1. The owned responsibility is defined by the host platform or integration boundary. -2. The repeated logic is infrastructure, not gameplay. -3. Variation can be expressed through strict typed inputs, descriptors, and callbacks. -4. Users can compose the package without casts, broad overloads, or explicit generic arguments for normal usage. - -If an abstraction mainly exists to hide game-specific decisions, it should stay in app code or examples, not in a public package. diff --git a/README.md b/README.md index 9b02f74..990215c 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,7 @@ input, timing, math, and renderer bridges. Drawing stays in the game.

-New to the library? Start with [CONCEPTS.md](./CONCEPTS.md) (the model in five -minutes), then [GAME_API.md](./GAME_API.md). Type architecture and storage are -documented in [ARCHITECTURE.md](./ARCHITECTURE.md). +## Quick start ```sh pnpm add @typeonce/ritmo @@ -40,77 +38,86 @@ the fix near its start, and the type tests check that every fix stays visible in TypeScript's default (truncated) message; with truncation off, an error that prints a whole schema can run to tens of kilobytes. -| Package | Contents | -|---|---| -| `@typeonce/ritmo` | The ECS runtime and the fixed-step loop | -| `@typeonce/ritmo-math` | Validated vectors, sizes, and bounding boxes; swept collision, spatial grid, flow field, deterministic random, and trig/exp/pow that return the same bits on every engine | -| `@typeonce/ritmo-browser` | Animation-frame tick source, keyboard, pointer, and touch input, input capture | -| `@typeonce/ritmo-render` | Entity to renderer node sync, for any renderer | -| `@typeonce/ritmo-pixi` | Pixi container adapter (`PixiNodeRegistry.make`) | -| `@typeonce/ritmo-devtools` | Headless debug sessions and trace reports ([README](./packages/devtools/README.md)) | -| `@typeonce/oxlint-plugin-ritmo` | Oxlint rules that keep simulation code deterministic and headless and runtime results checked ([README](./packages/oxlint-plugin/README.md)) | - -All packages are released together with the same version. - ```ts -import { Descriptor, Schema } from "@typeonce/ritmo" +import { Clock, Descriptor, Schema } from "@typeonce/ritmo" -// Define the ECS world shape once. +// Define the world once: components, resources, and the fixed step. +const step = Clock.hz(60) +const Time = Clock.resource("Example/Time", step) const Position = Descriptor.Component<{ x: number; y: number }>()("Example/Position") const Velocity = Descriptor.Component<{ x: number; y: number }>()("Example/Velocity") -const DeltaTime = Descriptor.Resource()("Example/DeltaTime") // Bind the schema to its game-facing APIs. -const Game = Schema.bind(Schema.fragment({ components: { Position, Velocity }, resources: { DeltaTime } })) +const Game = Schema.bind(Schema.fragment({ components: { Position, Velocity }, resources: { Time } })) + +const AdvanceTime = Clock.system(Game, { name: "Example/AdvanceTime", resource: Time }) -// Systems only receive the access they declare here. +// Systems receive only the access they declare. const Move = Game.System("Example/Move", { queries: { moving: Game.Query({ selection: { position: Game.Query.write(Position), velocity: Game.Query.read(Velocity) } }) }, - resources: { deltaTime: Game.System.readResource(DeltaTime) } + resources: { time: Game.System.readResource(Time) } }, ({ queries, resources }) => { + const { dt } = resources.time.get() for (const match of queries.moving.each()) { const position = match.data.position.get() const velocity = match.data.velocity.get() - match.data.position.set({ x: position.x + velocity.x * resources.deltaTime.get(), y: position.y + velocity.y * resources.deltaTime.get() }) + match.data.position.set({ x: position.x + velocity.x * dt, y: position.y + velocity.y * dt }) } }) -const runtime = Game.Runtime.make({ - services: Game.Runtime.services(), - resources: { DeltaTime: 1 / 60 } -}).value -runtime.tick(Game.Schedule(Move)) +// Starting can fail (the clock is a validated resource), and so can a tick: both return a Result. +const made = Game.Runtime.make({ services: Game.Runtime.services(), resources: { Time: Clock.start(step) } }) +if (made.ok) { + const runtime = made.value + const ticked = runtime.tick(Game.Schedule(AdvanceTime, Move)) + if (!ticked.ok) console.error(ticked.error) +} ``` -Start with the [Pixi walkthrough](./examples/pixi/README.md) for the full step-by-step example. +## Packages -## Runtime semantics +| Package | Contents | +|---|---| +| `@typeonce/ritmo` | The ECS runtime and the fixed-step loop | +| `@typeonce/ritmo-math` | Validated vectors, sizes, and bounding boxes; swept collision, spatial grid, flow field, deterministic random, and trig/exp/pow that return the same bits on every engine | +| `@typeonce/ritmo-browser` | Animation-frame tick source, keyboard, pointer, and touch input, input capture | +| `@typeonce/ritmo-render` | Entity to renderer node sync, for any renderer | +| `@typeonce/ritmo-pixi` | Pixi container adapter (`PixiNodeRegistry.make`) | +| `@typeonce/ritmo-devtools` | Headless debug sessions and trace reports ([README](./packages/devtools/README.md)) | +| `@typeonce/oxlint-plugin-ritmo` | Oxlint rules that keep simulation code deterministic and headless and runtime results checked ([README](./packages/oxlint-plugin/README.md)) | -- Descriptor identity is `(kind, name)`. A bound schema rejects two descriptors of one kind with the same name at compile time, and a look-alike descriptor with a different value type is not accepted in place of the registered one. -- Structural changes are never applied implicitly. Queued commands and machine transitions stay pending, across schedule runs if needed, until a schedule reaches `applyDeferred()` or `applyStateTransitions(...)`. -- Reads are per system: `added(...)`/`changed(...)` filters, removed/despawned reads, events, transition events, and relation failures show each system what was published since its own previous run, exactly once. -- Query results come back in spawn order. +All packages are released together with the same version. -## Performance +## Set up your agent -Queries only visit entities that carry their rarest required component, cache their match set until a component or relation they depend on changes membership, and reuse match objects between runs. See [ARCHITECTURE.md](./ARCHITECTURE.md#storage). +Your agent learns Ritmo from what installs with it: the source and its JSDoc +in `node_modules/@typeonce/*/src`, each package's README and changelog, and +one skill with the principles for structuring a game. -The benchmark suite in [`packages/core/bench`](./packages/core/bench) covers spawn/despawn, query iteration, change detection, structural churn, schedule overhead, lookups, events, relations, and type-checker cost; [`packages/math/bench`](./packages/math/bench) and [`packages/render/bench`](./packages/render/bench) cover per-frame vector math, swept collision, spatial grid, flow field, random, input axes, and render sync: +1. Install the packages the game uses, for example + `pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser` and + `pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint`. +2. Link the skill, so it updates with every Ritmo upgrade: -```sh -pnpm bench # run and print -pnpm bench:check # compare with packages/core/bench/baseline.json (exit 1 on regression) -pnpm bench:update # record a new baseline -``` + ```sh + mkdir -p .claude/skills + ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .claude/skills/ritmo # Codex: .agents/skills/ + ``` -CI measures every pull request against its base commit on the same runner and fails on runtime regressions over 25%. Type-checker growth over 5% produces a warning without blocking merges. Local `pnpm bench:check` still fails on both kinds of regression. +3. Ask the agent to set up `.oxlintrc.json` from the + [oxlint plugin README](https://www.npmjs.com/package/@typeonce/oxlint-plugin-ritmo) + and a lint entry for `ritmo-devtools lint` from the + [devtools README](https://www.npmjs.com/package/@typeonce/ritmo-devtools), + and to run both in the game's check. -## Naming note +## Guarantees -Ritmo was previously named `bevy-ts`. See the [migration guide](./MIGRATION.md) -for package replacements and the [release checklist](./RELEASING.md) for the -remaining registry and repository setup. Documentation lives in this repository -and in the source JSDoc. +- Names are unique: a bound schema rejects two descriptors of one kind with the same name at compile time. +- Nothing structural happens implicitly. Queued commands and state transitions wait for an explicit `applyDeferred()` or `applyStateTransitions(...)` in the schedule. +- Every system sees each change, event, and transition published since its own previous run, exactly once. +- Failures are values. A system that fails rolls back its writes, and the tick returns the failure. +- Queries visit only entities carrying their rarest required component and cache their matches. CI fails a pull request whose runtime benchmarks regress over 25%. -[Brand assets and palette](./brand/README.md) +Ritmo was previously named `bevy-ts`: see the [rename guide](./MIGRATION.md). +[Brand assets and palette](./brand/README.md) diff --git a/examples/algorithms/README.md b/examples/algorithms/README.md deleted file mode 100644 index 97bcff0..0000000 --- a/examples/algorithms/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Headless algorithms example - -Run `pnpm example:algorithms` from the workspace root. It prints where eight -chasers ended up, the shots' hits, and the random state. - -A 600 × 400 room with a wall down the middle, open at the bottom. Eight -chasers spawn on the left at random (from `Random`, its state kept in the -`Rng` resource), and every step they: - -- follow one shared `FlowField` around the wall toward the player (the search - runs again only when the player changes cell or the walls change), -- keep apart from nearby chasers, found through a `SpatialGrid` rebuilt each - step (it allocates nothing once warm), -- move with `Collide2.moveCircle`, sliding along walls instead of stopping. - -Shots move 67 units a step, much more than a wall is thick; each sweeps -against the walls and the chasers and stops at the earliest contact, so none -tunnels through. - -The simulation reads no wall clock or global random source: the frame time -comes in through the `Frame` service, so the same seed replays exactly -(`test/simulation.test.ts`). diff --git a/examples/concepts.ts b/examples/concepts.ts index a1d978c..c9bb4fa 100644 --- a/examples/concepts.ts +++ b/examples/concepts.ts @@ -1,7 +1,8 @@ /** - * The program behind CONCEPTS.md. It is typechecked with the repository and - * run by `packages/core/test/Concepts.test.ts`, so the guide cannot drift from - * the API. + * One small program that touches every core concept: descriptors, a closed + * schema, systems, commands, handles, events, failures, change detection, + * machines, and schedules. It is typechecked with the repository and run by + * `packages/core/test/Concepts.test.ts`. */ import { Decode, Descriptor, Result, Schema } from "@typeonce/ritmo" import type { Query } from "@typeonce/ritmo/Query" diff --git a/examples/pixi/README.md b/examples/pixi/README.md deleted file mode 100644 index 10b0428..0000000 --- a/examples/pixi/README.md +++ /dev/null @@ -1,294 +0,0 @@ -# Build The Pixi Example - -This walkthrough rebuilds [`examples/pixi/main.ts`](./main.ts) from zero to a complete app. - -The goal is to show the normal `ritmo` flow in order: - -1. define descriptors -2. build one closed schema and bind `Game` -3. define queries -4. define systems with explicit access -5. define schedules with explicit boundaries -6. create the runtime and host bridge -7. run the app from your outer loop - -## 1. Define the world shape - -Start by defining the ECS data you want to store. Components hold per-entity data. Resources hold singleton world values. Services expose host-owned capabilities, such as a renderer or clock. - -```ts -import { Descriptor, Schema } from "@typeonce/ritmo" -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" -import { Application, Container, Sprite, Texture } from "pixi.js" - -const Position = Descriptor.Component<{ x: number; y: number }>()("Pixi/Position") -const Velocity = Descriptor.Component<{ x: number; y: number }>()("Pixi/Velocity") -const Renderable = Descriptor.Component<{ size: number }>()("Pixi/Renderable") -const Tint = Descriptor.Component<{ value: number }>()("Pixi/Tint") - -const DeltaTime = Descriptor.Resource()("Pixi/DeltaTime") -const Viewport = Descriptor.Resource<{ width: number; height: number }>()("Pixi/Viewport") - -const PixiHost = Descriptor.Service<{ - readonly application: Application - readonly scene: Container - readonly clock: { deltaSeconds: number } -}>()("Pixi/PixiHost") - -// One Pixi sprite per rendered entity, mirrored by @typeonce/ritmo-render. -const Sprites = Descriptor.Service>()("Pixi/Sprites") -``` - -This split is the first important API rule: - -- ECS simulation state belongs in components and resources. -- Renderer objects stay outside ECS and are exposed through a typed service. -- Long-lived renderer references are host data, not proof that the ECS entity is still alive. - -## 2. Build one closed schema and bind `Game` - -Once the descriptors exist, assemble the schema and bind a single authoring surface. - -```ts -const pixiSchema = Schema.fragment({ - components: { Position, Velocity, Renderable, Tint }, - resources: { DeltaTime, Viewport } -}) - -const Game = Schema.bind(pixiSchema) -``` - -`Schema.bind(...)` is what gives you the runtime-connected API family: - -- `Game.Query` -- `Game.System` -- `Game.Command` -- `Game.Schedule` -- `Game.Runtime` - -Everything defined after this point is checked against the same closed world. - -## 3. Define queries for the exact reads you need - -Queries are explicit. You declare exactly which components are read or written, then optionally add filters. - -```ts -const Moving = Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.read(Velocity) - } -}) -``` - -- `write(...)` slots can be updated; `read(...)` slots are deeply readonly. -- `with` / `without` refine which entities match. -- `added(...)` / `changed(...)` filters match what changed since the reading system's previous run, so every system sees each change exactly once. - -## 4. Define systems with explicit declared access - -A system declares its entire dependency surface up front. The callback only receives what the spec asked for. - -Start with setup. This system reads the Pixi screen size through the service and queues entity spawns. - -```ts -const SetupSceneSystem = Game.System( - "Pixi/SetupSceneSystem", - { - services: { - pixi: Game.System.service(PixiHost) - }, - commands: Game.System.commands( - Game.System.spawns(Position, Velocity, Renderable, Tint) - ) - }, - ({ commands, services }) => - { - const { width, height } = services.pixi.application.screen - - commands.spawn( - Game.Command.spawn( - [Position, { x: width * 0.5, y: height * 0.5 }], - [Velocity, { x: 80, y: 60 }], - [Renderable, { size: 24 }], - [Tint, { value: 0xff6b35 }] - ) - ) - } -) -``` - -Now capture frame input from the host into ECS resources. - -```ts -const CaptureFrameInputSystem = Game.System( - "Pixi/CaptureFrameInputSystem", - { - resources: { - deltaTime: Game.System.writeResource(DeltaTime), - viewport: Game.System.writeResource(Viewport) - }, - services: { - pixi: Game.System.service(PixiHost) - } - }, - ({ resources, services }) => - { - resources.deltaTime.set(services.pixi.clock.deltaSeconds) - resources.viewport.set({ - width: services.pixi.application.screen.width, - height: services.pixi.application.screen.height - }) - } -) -``` - -Then define pure simulation systems. They only touch ECS data, so they do not need direct renderer access. - -```ts -const IntegrateMotionSystem = Game.System( - "Pixi/IntegrateMotionSystem", - { - queries: { - moving: Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.read(Velocity) - } - }) - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime) - } - }, - ({ queries, resources }) => - { - const dt = resources.deltaTime.get() - - for (const match of queries.moving.each()) { - const position = match.data.position.get() - const velocity = match.data.velocity.get() - - match.data.position.set({ - x: position.x + velocity.x * dt, - y: position.y + velocity.y * dt - }) - } - } -) -``` - -The full example adds a `BounceWithinViewportSystem` as the next simulation step. It reads `Viewport`, writes `Position` and `Velocity`, and clamps or flips movement at the edges. - -## 5. Bridge ECS changes back into Pixi - -`RenderSync` from `@typeonce/ritmo-render` builds the one system every renderer bridge needs: it creates a sprite when an entity gains `Renderable`, moves it when `Position` changes, and destroys it when the entity loses `Renderable` or despawns. - -```ts -const render = RenderSync.system(Game, { - name: "Pixi/Render", - renderable: Renderable, - transform: Position, - registry: Sprites, - select: { tint: Game.Query.read(Tint) }, - create: ({ renderable, data }) => { - const sprite = new Sprite(Texture.WHITE) - sprite.anchor.set(0.5) - sprite.width = renderable.size - sprite.height = renderable.size - sprite.tint = data.tint.get().value - return sprite - }, - apply: (sprite, { transform }) => { - sprite.position.set(transform.x, transform.y) - } -}) -``` - -The important part is the boundary: - -- ECS owns the intent to render. -- Pixi owns the actual renderer object; you decide what it looks like. -- The registry service is a normal requirement: a runtime that does not provide `Sprites` cannot tick this system. - -## 6. Make schedule boundaries visible - -Schedules define when deferred writes become visible. - -```ts -const setupSchedule = Game.Schedule( - SetupSceneSystem, - Game.Schedule.applyDeferred(), - render -) - -const updateSchedule = Game.Schedule( - CaptureFrameInputSystem, - IntegrateMotionSystem, - BounceWithinViewportSystem, - render -) -``` - -This is why the walkthrough builds in this order: - -- `SetupSceneSystem` queues entity spawns. -- `applyDeferred()` commits those queued commands. -- `render` runs after the commit, so it creates sprites for the new entities on this run. - -The same rule applies every frame. Schedule markers are explicit runtime semantics, not hidden engine magic: nothing is flushed when a schedule ends, so work queued after the last marker stays pending until a later schedule reaches one. - -## 7. Build the runtime and start the app - -Create the host objects first, then inject them into the runtime through typed services and resources. - -```ts -const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(PixiHost, host), - Game.Runtime.service(Sprites, PixiNodeRegistry.make(host.scene)) - ), - resources: { - DeltaTime: host.clock.deltaSeconds, - Viewport: { - width: application.screen.width, - height: application.screen.height - } - } -}).value - -runtime.tick(setupSchedule) -runtime.tick(updateSchedule) -``` - -Finally, keep the outer loop outside ECS and call `runtime.tick(...)` yourself. - -```ts -const tick = (ticker: { readonly deltaMS: number }) => { - host.clock.deltaSeconds = ticker.deltaMS / 1000 - runtime.tick(updateSchedule) -} - -application.ticker.add(tick) -``` - -That is the core `ritmo` shape: - -- host loop outside the ECS -- runtime values injected explicitly -- systems with declared access only -- schedule markers controlling visibility and timing - -## Read the full source - -The complete version, including sprite creation and viewport bounce logic, is in [`examples/pixi/main.ts`](./main.ts). - -From here, the API reference pages are the next step if you want exact definitions for the surfaces used above: - -- `Descriptor` -- `Schema` -- `Query` -- `System` -- `Schedule` -- `Runtime` diff --git a/examples/recipes.ts b/examples/recipes.ts deleted file mode 100644 index 30ba667..0000000 --- a/examples/recipes.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** Simulation-owned time and random state. No clock or host capture is needed. */ -import { Decode, Descriptor, Schema } from "@typeonce/ritmo" - -const Time = Descriptor.ConstructedResource(Decode.struct({ step: Decode.integer, elapsed: Decode.number, dt: Decode.number }))("Recipes/Time") -const Random = Descriptor.ConstructedResource(Decode.integer)("Recipes/Random") -const Game = Schema.bind(Schema.fragment({ resources: { Time, Random } })) - -const AdvanceTime = Game.System("Recipes/AdvanceTime", { - resources: { time: Game.System.writeResource(Time) } -}, ({ resources }) => { - resources.time.update((time) => ({ ...time, step: time.step + 1, elapsed: time.elapsed + time.dt })) -}) - -/** A pure 32-bit generator: the returned seed is the next world value. */ -const next = (seed: number): { readonly seed: number; readonly value: number } => { - const state = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - return { seed: state, value: state / 4294967296 } -} - -const Roll = Game.System("Recipes/Roll", { - resources: { random: Game.System.writeResource(Random) } -}, ({ resources }) => { - const rolled = next(resources.random.get()) - resources.random.set(rolled.seed) - // Use rolled.value for this system's random decision. -}) - -export const runRecipes = () => { - const made = Game.Runtime.make({ services: Game.Runtime.services(), resources: { Time: { step: 0, elapsed: 0, dt: 1 / 60 }, Random: 42 } }) - if (!made.ok) throw new Error("invalid fixture") - const runtime = made.value - const update = Game.Schedule(AdvanceTime, Roll) - const before = runtime.snapshot() - if (!runtime.tick(update).ok) throw new Error("the update failed") - const after = runtime.snapshot() - if (!runtime.restore(before).ok) throw new Error("the snapshot did not restore") - if (!runtime.tick(update).ok) throw new Error("the replayed update failed") - return { after, replay: runtime.snapshot() } -} diff --git a/packages/browser/CHANGELOG.md b/packages/browser/CHANGELOG.md index 666c3f2..e449cf1 100644 --- a/packages/browser/CHANGELOG.md +++ b/packages/browser/CHANGELOG.md @@ -4,7 +4,7 @@ ### Minor Changes -- 6c09482: **`FixedLoop` moves to core, so any host can drive it.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 6c09482: **`FixedLoop` moves to core, so any host can drive it.** See Upgrading under 0.9.0 in the `@typeonce/ritmo` CHANGELOG.md. - **`FixedLoop` is now exported from `@typeonce/ritmo`** (`import { FixedLoop } from "@typeonce/ritmo"`). Its API is unchanged. `@typeonce/ritmo-browser` no longer exports it. - **`BrowserTickSource.make(window)`** in `@typeonce/ritmo-browser` replaces `FixedLoop.animationFrames(window)`. It is the browser's `TickSource`, named like `BrowserHostClock`. Native shells, desktop runtimes, and renderer tickers wrap their own frames with `FixedLoop.source(...)`. diff --git a/packages/browser/README.md b/packages/browser/README.md index b02572f..fea745a 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -1,7 +1,5 @@ # `@typeonce/ritmo-browser` -Migrating from bevy-ts? See the [package migration guide](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md). - Browser integrations for ritmo: animation frames as a tick source for the core fixed-step loop (`BrowserTickSource`), keyboard actions with press/release edges, bound by character or by physical key (`Keyboard`), mouse and pen position and button edges over an element (`Pointer`), on-screen touch controls with a floating stick and drag-to-aim buttons (`Touch`), scripted and recorded keyboard and pointer timelines for tests and replays, and a system that copies input into a resource (`InputCapture`). ```sh diff --git a/packages/browser/package.json b/packages/browser/package.json index b703d58..04641a0 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -27,6 +27,7 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "exports": { diff --git a/packages/browser/src/InputCapture.ts b/packages/browser/src/InputCapture.ts index a482911..6739400 100644 --- a/packages/browser/src/InputCapture.ts +++ b/packages/browser/src/InputCapture.ts @@ -12,6 +12,9 @@ * directly, or a game-specific adapter that merges several devices. The * resource's value type must be exactly the snapshot type. * + * Its spec is erased, so it does not count as a writer of the resource for + * `Game.System.thisTick(...)` order checks: place it first yourself. + * * @module InputCapture * @docGroup browser * diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 3d961d2..6811d73 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -2,6 +2,128 @@ ## 0.9.0 +### Upgrading + +0.9.0 rejects several calls that compiled before but were always wrong. +Fix each error where it is reported; the messages name the fix. + +- **`Runtime.make` always returns a `Result`.** When nothing provided can + fail, its type is `Success`: read `.value`. When a constructed resource + may be provided, check it as before. + + ```diff + -const runtime = Game.Runtime.make({ services, resources: { Score: 0 } }) + +const runtime = Game.Runtime.make({ services, resources: { Score: 0 } }).value + ``` + +- **A query match is valid only during its run.** Keeping a match in + `local` and reading or writing it later throws once its entity was + despawned or lost the component. Keep the entity id or a handle, and + resolve it with `queries.x.get(id)` / `getHandle(handle)`. +- **A look-alike descriptor must behave like the registered one.** A + descriptor built elsewhere with the same kind and name but different + behaviour (an entity state, a constructor, `immutable`, transient) throws + when its query first runs; use the descriptor the schema registers. +- **New lint `next-state-replaced`.** On a debug runtime, two systems + queuing different next states for one machine before the same marker are + reported. Decide in one system, or check `nextMachines.x.getPending()` + before setting. + +- **Draft relations need `relates`.** A system that spawns a draft built + with `Game.Command.relate(...)` declares the relation: + + ```diff + -commands: Game.System.commands(Game.System.spawns(Errand)) + +commands: Game.System.commands(Game.System.spawns(Errand), Game.System.relates(WalkerOf)) + ``` + + The `relation-failure-never-read` lint then sees these relations: read + their failures (`Game.System.readRelationFailures(WalkerOf)`) and handle + them, for example by despawning a child whose parent was gone when it + spawned. + +- **A command target picked from several queries must be valid for each.** + `commands.despawn(cond ? a.entity : b.entity)` needs both queries to prove + a `despawns` component; resolve the entity through one query, or branch + and call the command in each branch. A raw id is never a target, not even + as one side of a conditional. +- **Conditional and spread entries count as maybe present.** A requirer a + draft may hold needs its requirements surely held: + `spawn(Game.Command.spawn(...parts))` with a widened array fails when a + part requires another; list the required components as fixed entries. +- **`Runtime.make` names unknown resource keys.** Fix the spelling, or + remove the key. +- **Queries that can never match fail to compile.** Remove the component + from `without` or from the required side. +- **A bundle's transition schedules require their machine.** Provide it with + `Game.Runtime.machine(...)`, or drop the schedule. +- **Registries take their own kind.** Move a resource out of `components`, + and so on. +- **Type-level:** `EntityRef` / `EntityMut` `Proves` and `Lacks` parameters + are contravariant and default to `never`; `EntityDraft` has a fourth + parameter (`Relates`). Code that names these types with explicit + arguments updates them; inferred code is unchanged. +- **Removed duplicates.** Use `Descriptor.Hierarchy` / `Descriptor.Relation` + instead of `Relation.Hierarchy` / `Relation.Relation`, and + `Schema.fragment({})` instead of `Schema.empty()`. +- **Permissions name at least one entry**, and a system declares one slot + per resource (a write or changed view reads too). +- **Optional slots read with `value()`**: `cell.value()` replaces + `cell.present ? cell.get() : undefined`. +- **A `SpawnedId` is valid only in the run that spawned it.** A `despawn` + or `remove` given one kept from an earlier run is skipped and reported + by the `stale-target` lint. Keep the entity id or a handle and resolve it + through a query (`queries.x.get(id)`), then pass `match.entity`. +- **Descriptor values cannot be `any` or `never`.** Give the value a type; + use `unknown` for a value no system reads, and `Descriptor.Tag` for a + marker. +- **A saved component cannot require a transient one.** Make the requirer + transient too, or give the required component a constructor. +- **Filtered queries have no lookups.** Resolve entities through a second + query without `added` / `changed` filters: + + ```diff + -Game.System("Follow", { queries: { moved: Moved } }, ({ queries }) => { + - const target = queries.moved.getHandle(handle) + +Game.System("Follow", { queries: { moved: Moved, targets: Targets } }, ({ queries }) => { + + const target = queries.targets.getHandle(handle) + ``` + +- **`onExit` / `onTransition` schedules cannot contain `applyDeferred()`.** + Their commands are applied at the next `applyDeferred()` after the + marker; a failure in the exit phase now undoes the whole phase. +- **Provide each service and machine once** in `Runtime.services(...)` / + `Runtime.machines(...)`. +- **Snapshots carry `gameVersion`.** Saves without it restore as version + 0. Code that builds a `Snapshot.WorldSnapshot` by hand adds + `gameVersion: 0`. To change a game's save format, pass + `saves: Snapshot.migrations(...)` to `Game.Runtime.make` instead of editing + raw saves before `restore`. +- **Keep random state with `Random.draw`.** Declare the resource with + `Descriptor.ConstructedResource(Random)`, start it with `Random.seed(n)` + (the runtime is then made as a `Result`), and draw with + `Random.draw(resources.rng)` instead of `Random.make(state)` plus a `set`. + `Random.integer` / `range` now throw on bounds with nothing to draw, and + `SpatialGrid` on non-finite input. +- **New lints `stale-target` and `stream-loss-unread`** on debug runtimes. + For the second, check `lagged()` where a missed entry matters and rebuild + from queries when it is true. +- **Behaviour:** `Collide2` now stops a circle that starts exactly touching + a wall and moves into it. A game that fingerprints its simulation + re-records the fingerprints with the upgrade. + +- **`FixedLoop` moved to `@typeonce/ritmo`.** Import it from core. The + browser's animation-frame source is now `BrowserTickSource.make(window)`; + a source wrapped with `FixedLoop.source(...)` is unchanged. + + ```diff + -import { FixedLoop } from "@typeonce/ritmo-browser" + -const source = FixedLoop.animationFrames(window) + +import { FixedLoop } from "@typeonce/ritmo" + +import { BrowserTickSource } from "@typeonce/ritmo-browser" + +const source = BrowserTickSource.make(window) + ``` + ### Minor Changes - c2c32e0: **API additions from a second review.** @@ -12,7 +134,7 @@ - **Save migrations.** `Game.Runtime.make({ saves: Snapshot.migrations(Snapshot.migration(1, ...), Snapshot.migration(2, ...)) })`: snapshots carry a `gameVersion` (the last step; 0 without migrations and for older saves), and `restore` runs every step after the save's version in order, checking the structure after each, before validating as usual. A save newer than the last step fails with `NewerGameVersion`. Steps must be numbered 1, 2, 3, ... in order (checked at compile time and at runtime). - **Docs** now state that a system does not see its own writes as changes (it does see its own events), how long `Condition.stateChanged` holds, that a sub-state value set at the marker its parent leaves is dropped, and that `restore` keeps a current resource the save lacks. -- 9cca78b: **One shape for `Runtime.make`, and runtime guards from a review of 0.8.0.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 9cca78b: **One shape for `Runtime.make`, and runtime guards from a review of 0.8.0.** See Upgrading above. - **Breaking: `Runtime.make` always returns a `Result`.** It returned the bare runtime or a `Result` depending on the resource values present at runtime, while its type decided from the keys of the provided type, so resources typed `{ Score?: ... }` produced a bare runtime the type called a `Result`. The type is now `Success` when nothing provided can fail (read `.value`), and a `Result` to check when a constructed resource may be provided. - **`thisTick` checks a writer that shares its reader's display name.** Systems may share a display name; the static check skipped any writer named like the reader. It now tells the reader apart by its system type. @@ -21,13 +143,13 @@ - **A query descriptor that behaves unlike the registered one throws**: a look-alike (same kind and name) that adds or drops entity-state transitions, a constructor, immutability, or transience. Look-alikes that behave the same keep working. - `Scalar.Finite` and the `Vector2` / `Scalar` arithmetic helpers document that results keep the brand without re-validating, finite while they stay within `±Number.MAX_VALUE`. -- 6c09482: **`FixedLoop` moves to core, so any host can drive it.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 6c09482: **`FixedLoop` moves to core, so any host can drive it.** See Upgrading above. - **`FixedLoop` is now exported from `@typeonce/ritmo`** (`import { FixedLoop } from "@typeonce/ritmo"`). Its API is unchanged. `@typeonce/ritmo-browser` no longer exports it. - **`BrowserTickSource.make(window)`** in `@typeonce/ritmo-browser` replaces `FixedLoop.animationFrames(window)`. It is the browser's `TickSource`, named like `BrowserHostClock`. Native shells, desktop runtimes, and renderer tickers wrap their own frames with `FixedLoop.source(...)`. - **Package sources compile without DOM types.** Core, math, render, browser, and pixi reach no DOM global, and the compiler now rejects one. Published code is unchanged. -- ecfd0f1: **One way to do each thing, and docs that match the code.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- ecfd0f1: **One way to do each thing, and docs that match the code.** See Upgrading above. - **Removed** `Relation.Hierarchy` / `Relation.Relation` (use `Descriptor.Hierarchy` / `Descriptor.Relation`), `Schema.empty()` (use `Schema.fragment({})`), and `Debug.MemberFor` (described the removed `debug: true`). `Relation.PairBrand` is now exported as a type. - **Command permissions need at least one entry**: `Game.System.spawns()` with nothing no longer compiles (it made `commands.spawn` say spawn is not declared). @@ -35,7 +157,7 @@ - **`value()` on optional query cells** returns the value, or `undefined` when the entity does not hold the component. - JSDoc now matches the code: `Game.Command.spawn` and `entryRaw` name `extend`, `Schedule.when` says bundle transition schedules are not gated, `Clock.every` is false on step 0, and `EntityStateWriteCell.transition` says when to use `set` instead. -- 330246e: **Safety follow-ups from a second review.** Each closes a way a game could go wrong silently; see [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 330246e: **Safety follow-ups from a second review.** Each closes a way a game could go wrong silently; see Upgrading above. - **A `SpawnedId` is valid only in the run that spawned it.** Kept in a closure or resource and passed to `commands.despawn` / `commands.remove` in a later run, it could name an entity despawned meanwhile whose id was reused. Such a command is now skipped, traced as a `staleTarget` effect, and reported by the new `stale-target` lint (`Debug.StaleTarget`). Keep the entity id or a handle and resolve it through a query. - **`any` and `never` are not descriptor values.** `Descriptor.Component()`, `Resource`, `Event`, `Service`, and the transient variants fail at the constructor: `any` turned off every check on the descriptor, and `never` erased the fragment listing it. @@ -46,7 +168,7 @@ - **`RenderSync` recovers from dropped removals.** After a pause longer than a stream keeps entries, the removals it missed left nodes on screen; when its removal stream reports `lagged()`, it now destroys every node whose entity no longer matches. Give each `RenderSync.system` its own registry. - **New lint `stream-loss-unread`.** On a debug runtime, a reader of events, transition events, removed, despawned, or relation failures that lost entries without checking `lagged()` is a warning, and one reading under `pauseWhen` without checking it is noted (`Debug.StreamLoss`). -- 7f2b2dd: **Stricter types from a review of 0.8.0.** Each change rejects code that was always wrong; see [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 7f2b2dd: **Stricter types from a review of 0.8.0.** Each change rejects code that was always wrong; see Upgrading above. - **A command target chosen from several queries is checked for every one of them.** `commands.despawn(cond ? a.entity : b.entity)`, `insert`, and `remove` compiled when either target was allowed, and a raw id joined with a match (`cond ? match.entity : match.entity.id`) passed as a match. `EntityRef` / `EntityMut` proof parameters are now contravariant (proving nothing is `never`), so a union of refs keeps only what all of them prove. - **Drafts and inserts check components they may hold.** A conditional or spread entry holding a component with requirements now needs those requirements surely present, in `commands.spawn` and `commands.insert`. Fixed entries before or after a spread count as held. @@ -74,6 +196,63 @@ ## 0.8.0 +### Upgrading + +0.8.0 adds component requirements (`Schema.requires`) and changes how +`commands.insert` and `commands.remove` take their target. + +- **`remove` takes a query match, not a raw id.** Pass `match.entity` (or the + id `commands.spawn` returned in the same run). With only a stored id, + resolve it first. + + ```diff + -for (const match of queries.marked.each()) commands.remove(match.entity.id, Marker) + +for (const match of queries.marked.each()) commands.remove(match.entity, Marker) + + -commands.remove(storedId, Sprite) + +const found = queries.sprites.get(storedId) + +if (found.ok) commands.remove(found.value.entity, Sprite) + ``` + + A raw id fails with an error that first prints the id's type (the whole + schema), then the fix: `remove needs an entity from a query (pass + match.entity) or the id spawn returned in this run, not a raw id`. + +- **`remove` takes several components.** Removing a component together with + the components that require it must happen in one call: + `commands.remove(match.entity, Player, Velocity)`. +- **`insert` takes `match.entity` too.** A raw id still works for components + that require nothing; a component with requirements needs a target whose + query proves them, or the requirements inserted in the same call. +- **A query's `without` proves absence.** `remove(match.entity, Velocity)` + compiles when every component that requires `Velocity` is excluded by the + query (`without: [Player]`) or removed in the same call. +- **Hand-kept part lists can go.** Declare what always comes together in the + schema, and give `spawns` the main component: + + ```diff + -export const PlayerParts = [Position, Velocity, Collider, Player] as const + -commands: Game.System.commands(Game.System.spawns(...PlayerParts)) + +Schema.fragment({ components: { ... }, requires: [Schema.requires(Player, Position, Velocity, Collider)] }) + +commands: Game.System.commands(Game.System.spawns(Player)) + ``` + + - Put a requirement on the component that needs it, not on widely shared + ones. If many components require `Position` or `Renderable`, every + `remove` of those needs a query with `without` for each requirer. + - Only `spawns(...)` expands requirements. `inserts(...)`, `removes(...)`, + and `remove(...)` do not, so a list such as "every part of a body" stays a + plain array: `removes(...BodyParts)` and `remove(match.entity, ...BodyParts)`. + - Components a draft may hold that no requirement covers (optional parts) + are still listed in `spawns(...)` next to the main component. + +- **New optional descriptor option.** `{ immutable: true }` on a component + makes `Game.Query.write` of it a compile error; replace it with `insert`. +- **Debug output.** `describe()` component entries gain `immutable` and + `requires`; a new effect kind `requirementViolated` and lint + `requirement-violated` report commands skipped at apply time. Replay schema + hashes are unchanged. + ### Minor Changes - 0b22113: **Immutable components.** `Descriptor.Component`, `ConstructedComponent`, and `TransientComponent` take an optional `{ immutable: true }` for data that says what an entity is (a kind, a team, a tile). Queries can only read such a component: `Game.Query.write(Kind)` is a compile error naming the fix (`replace it with commands.insert(entity, Game.Command.entry(Kind, ...))`), and it throws the same message when an erased type reaches it. Replacing the component with `commands.insert` still marks it changed for `Game.Query.changed(Kind)`. Descriptors declared without the option keep their exact type. @@ -86,7 +265,7 @@ - `RenderSync.system` now builds one query per sync system (an `anyOf` of its added and changed filters) instead of one query per trigger plus deduplication. - `Debug.QueryDescription` gains `anyOf` (one entry per `anyOf` filter, with its `added` and `changed` components); code that builds a `QueryDescription` by hand must add it. The text report prints it as `any of (...)`, and Monitor counts its components as reads. -- 0b22113: **Breaking: required components; `insert` and `remove` take a query match.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-080). +- 0b22113: **Breaking: required components; `insert` and `remove` take a query match.** See Upgrading above. A fragment declares what always comes together: `Schema.fragment({ components: { ... }, requires: [Schema.requires(Player, Position, Velocity)] })`. Requirements chain, may name components of other fragments of the same `Schema.bind` (checked there), and are never filled in with defaults. diff --git a/packages/core/README.md b/packages/core/README.md index 0419493..697eda5 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,7 +1,5 @@ # `@typeonce/ritmo` -Migrating from bevy-ts? See the [package migration guide](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md). - The agent-first type safe ECS runtime Closed schemas, declared system access, explicit schedule boundaries, typed @@ -20,6 +18,8 @@ const Game = Schema.bind(Schema.fragment({ components: { Position } })) Modules are also importable by path, for example `@typeonce/ritmo/Result`. +The API reference is the JSDoc in `src/`: each module's header says what it is for and which neighbouring API to use instead. Upgrade notes are in `CHANGELOG.md`; breaking entries name the fix. The agent skill for building games ships in `skills/ritmo/SKILL.md`; link it into the game with `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .claude/skills/ritmo` (Codex: `.agents/skills/`). + Related packages, released together with the same version: - [`@typeonce/ritmo-math`](https://www.npmjs.com/package/@typeonce/ritmo-math): validated vectors, sizes, and bounding boxes diff --git a/packages/core/package.json b/packages/core/package.json index e6e2f1d..379d64a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,6 +28,8 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", + "skills", "LICENSE" ], "exports": { diff --git a/packages/core/skills/ritmo/SKILL.md b/packages/core/skills/ritmo/SKILL.md new file mode 100644 index 0000000..e440dc5 --- /dev/null +++ b/packages/core/skills/ritmo/SKILL.md @@ -0,0 +1,52 @@ +--- +name: ritmo +description: "Apply when building or changing a game on @typeonce/ritmo: gameplay, content, readouts, UI, saves, host integration, splitting modules, or upgrading Ritmo. Where the Ritmo docs are, and principles for where code and state belong so changes stay local and mistakes fail early." +--- + +# Ritmo games + +Follow the game's existing structure and conventions. These principles decide where something belongs; the game decides its folders, UI framework, renderer, timing, and design. + +## Where the docs are + +- The API reference is the JSDoc in `node_modules/@typeonce/*/src`. Read a module's header before using the module, and an export's JSDoc before calling it. Do not work from memory or the web: the API changes between versions. +- Each package's `README.md` covers its setup: `@typeonce/oxlint-plugin-ritmo` for `.oxlintrc.json`, `@typeonce/ritmo-devtools` for headless sessions and `ritmo-devtools lint`. +- When upgrading, read each package's `CHANGELOG.md` from the installed version up. Breaking entries name the fix. +- Compiler errors and lint messages name the fix. Apply it; never cast around it. + +## Principles + +**The simulation is the game; everything else is a host.** +- Put rules that change the world in systems. Put input capture, drawing, UI, storage, network, and audio in host code. +- Treat any code that changes saved world state as gameplay, wherever it lives. + +**Input goes in as intents; output comes out as reads.** +- Turn player actions into typed host events (`runtime.send(...)`) or input resources that one system validates and applies. UI and render code never change saved world state. + +**Each fact has one home.** +- Define each closed set (items, tools, building kinds) once. Derive labels, shortcuts, menus, and lookups from it. +- Type tables that must cover the whole set with `satisfies Record`, so a new id fails to compile until every table has an entry. Keep optional overrides `Partial` with an explicit default. + +**Each piece of state has an owner and a lifetime.** +- For every new mutable value, choose its home: saved world state (a component or resource), unsaved runtime state (a transient component or resource, or the system's `local`), or host state (a service or host code). Decide what happens to it on new game, restore, and dispose. +- Keep gameplay state (counters, ids, timers, flags) out of module variables: they survive restore, ignore rollback, and are shared by every runtime in the process. A cache inside a pure helper, keyed by its whole input, is fine. +- When moving code, keep each shared cache, pool, and long-lived host object single. + +**Extend what exists before adding.** +- Search for an existing system, component, definition, or UI piece first. Extend it or add a variant instead of writing a near copy. +- Add a system only for behaviour no existing one covers. New content that reuses behaviour is data. +- Give UI pieces a narrow view model and intent callbacks, not the runtime or a whole host object. + +**Make the next mistake fail loudly.** +- When a rule can be a type, a lint, or a headless test, encode it there instead of in a comment. +- Verify a gameplay change with a headless run that asserts the observable outcome. Reproduce a bug headless before fixing it, then keep the reproduction as a test. +- Keep `oxlint` and `ritmo-devtools lint ` in the game's check. +- Test the simulation with `Determinism.check` from `@typeonce/ritmo-devtools`: it runs it twice and names the first value that differs, which catches hidden inputs and module state. + +## Self-check + +- Does any UI or render code change saved world state? +- Did adding this id produce a type error everywhere it is needed? +- What happens to this value on restore? +- Did I extend an existing piece, or copy it? +- What check fails if someone breaks this tomorrow? diff --git a/packages/core/src/Command.ts b/packages/core/src/Command.ts index e7adb1c..f18e439 100644 --- a/packages/core/src/Command.ts +++ b/packages/core/src/Command.ts @@ -19,22 +19,27 @@ * @example * ```ts * // Build drafts as values so spawn intent stays explicit inside the system. - * const EnemyWave = Game.System("EnemyWave", {}, ({ commands }) => { - * const enemy = Game.Command.spawn( - * // Validate raw authored input through the constructed descriptor. - * Game.Command.entryRaw(Position, { x: 96, y: 32 }), - * // Add already-validated marker or config components directly. - * [Enemy, {}], - * [Health, 3] - * ) + * const EnemyWave = Game.System( + * "Game/EnemyWave", + * // Declare which components this system may spawn. + * { commands: Game.System.commands(Game.System.spawns(Position, Enemy, Health)) }, + * ({ commands }) => { + * const enemy = Game.Command.spawn( + * // Validate raw authored input through the constructed descriptor. + * Game.Command.entryRaw(Position, { x: 96, y: 32 }), + * // Add already-validated marker or config components directly. + * [Enemy, {}], + * [Health, 3] + * ) * - * if (!enemy.ok) { - * return - * } + * if (!enemy.ok) { + * return + * } * - * // Queue the world write now. The entity becomes visible later. - * commands.spawn(enemy.value) - * }) + * // Queue the world write now. The entity becomes visible later. + * commands.spawn(enemy.value) + * } + * ) * * // Make the deferred mutation boundary part of the schedule itself. * const update = Game.Schedule( diff --git a/packages/core/src/Decode.ts b/packages/core/src/Decode.ts index 00acf4c..11f6a86 100644 --- a/packages/core/src/Decode.ts +++ b/packages/core/src/Decode.ts @@ -6,10 +6,10 @@ * shapes of game state so most descriptors need no hand-written validation: * * ```ts - * const Position = Descriptor.ConstructedComponent(Decode.struct({ x: Decode.number, y: Decode.number }))("Position") - * const Health = Descriptor.ConstructedComponent(Decode.integer)("Health") - * const Target = Descriptor.ConstructedComponent(Decode.struct({ enemy: Decode.handle(Root, Health) }))("Target") - * const Player = Descriptor.Tag("Player") + * const Position = Descriptor.ConstructedComponent(Decode.struct({ x: Decode.number, y: Decode.number }))("Game/Position") + * const Health = Descriptor.ConstructedComponent(Decode.integer)("Game/Health") + * const Target = Descriptor.ConstructedComponent(Decode.struct({ enemy: Decode.handle(Root, Health) }))("Game/Target") + * const Player = Descriptor.Tag("Game/Player") * ``` * * Each validator is a `Codec`: `result` checks already-typed input (used by @@ -107,7 +107,7 @@ export const boolean: Codec = primitive("boolean", (raw): raw is boolea * * @example * ```ts - * const Team = Descriptor.ConstructedComponent(Decode.literal("red", "blue"))("Team") + * const Team = Descriptor.ConstructedComponent(Decode.literal("red", "blue"))("Game/Team") * ``` */ export const literal = ]>( @@ -155,7 +155,7 @@ export const array = (item: Codec): Codec> => { * * @example * ```ts - * const Velocity = Descriptor.ConstructedComponent(Decode.struct({ x: Decode.number, y: Decode.number }))("Velocity") + * const Velocity = Descriptor.ConstructedComponent(Decode.struct({ x: Decode.number, y: Decode.number }))("Game/Velocity") * ``` */ export const struct = >>>( diff --git a/packages/core/src/Descriptor.ts b/packages/core/src/Descriptor.ts index 2aad828..57d44e4 100644 --- a/packages/core/src/Descriptor.ts +++ b/packages/core/src/Descriptor.ts @@ -20,6 +20,11 @@ * matter is a state machine (`Game.StateMachine(...)`); a state each entity * has on its own is a component (`Descriptor.EntityState(...)`). * + * Identity is `(kind, name)`: names are unique per kind across everything + * bound with `Schema.bind`, and a clash between fragments is rejected there. + * Prefix every name with the game or package (`"Game/Position"`), as the + * `ritmo/prefix-descriptor-and-system-names` lint rule requires. + * * @module Descriptor * @docGroup core * @@ -41,16 +46,16 @@ * @example * ```ts * // Components describe per-entity state that queries can match. - * const Position = Descriptor.Component<{ x: number; y: number }>()("Position") + * const Position = Descriptor.Component<{ x: number; y: number }>()("Game/Position") * * // Resources describe singleton world data shared across systems. - * const DeltaTime = Descriptor.Resource()("DeltaTime") + * const DeltaTime = Descriptor.Resource()("Game/DeltaTime") * * // Events describe messages between systems, read once per reader. - * const DamageTaken = Descriptor.Event<{ amount: number }>()("DamageTaken") + * const DamageTaken = Descriptor.Event<{ amount: number }>()("Game/DamageTaken") * * // Services describe host capabilities that live outside ECS storage. - * const Logger = Descriptor.Service<{ log: (message: string) => void }>()("Logger") + * const Logger = Descriptor.Service<{ log: (message: string) => void }>()("Game/Logger") * ``` */ export type DescriptorTypeId = "~ritmo/Descriptor" @@ -311,9 +316,9 @@ type CheckedValue = [ValueRejection()("Position") + * const Position = Descriptor.Component<{ x: number; y: number }>()("Game/Position") * // What an entity is, replaced with commands.insert and never written by a query: - * const EnemyKind = Descriptor.Component<"grunt" | "boss">()("EnemyKind", { immutable: true }) + * const EnemyKind = Descriptor.Component<"grunt" | "boss">()("Game/EnemyKind", { immutable: true }) * ``` */ export const Component = (): CheckedValue<"component", Value, ( @@ -332,7 +337,7 @@ export const Component = (): CheckedValue<"component", Value, ( @@ -351,7 +356,7 @@ export const ConstructedComponent = ()("SpriteRef") + * const SpriteRef = Descriptor.TransientComponent<{ frame: number }>()("Game/SpriteRef") * ``` */ export const TransientComponent = (): CheckedValue<"component", Value, ( @@ -368,8 +373,8 @@ export const TransientComponent = (): CheckedValue<"component", Value, ( @@ -418,10 +423,13 @@ export interface EntityStateDescriptor< * happens only if the current state is still `from`, otherwise it returns an * `EntityStateMismatch` failure. Transitions are ordinary component writes: * immediate, visible to later systems, rolled back with a failed system. + * The graph lists every state (`[]` for one with no way out). There are no + * per-state schedules: branch on `get()` in the system that owns the + * component, and emit an event on a transition other systems must react to. * * @example * ```ts - * const Attack = Descriptor.EntityState("Attack", ["ready", "windup", "active"] as const, { + * const Attack = Descriptor.EntityState("Game/Attack", ["ready", "windup", "active"] as const, { * transitions: { ready: ["windup"], windup: ["active", "ready"], active: ["ready"] } * }) * // in a system with a write slot `attack`: @@ -463,7 +471,7 @@ export const isEntityState = ( * @example * ```ts * // Store shared world state once and request it explicitly from systems. - * const Score = Descriptor.Resource()("Score") + * const Score = Descriptor.Resource()("Game/Score") * ``` */ export const Resource = (): CheckedValue<"resource", Value, ( @@ -486,7 +494,7 @@ export const ConstructedResource = ()("DeltaTime") + * const DeltaTime = Descriptor.TransientResource()("Game/DeltaTime") * ``` */ export const TransientResource = (): CheckedValue<"resource", Value, ( @@ -508,7 +516,7 @@ export const TransientResource = (): CheckedValue<"resource", Value, ()("Hit") + * const Hit = Descriptor.Event<{ target: number; amount: number }>()("Game/Hit") * ``` */ export const Event = (): CheckedValue<"event", Value, ( @@ -522,14 +530,17 @@ export const Event = (): CheckedValue<"event", Value, void }>()("Logger") + * const Logger = Descriptor.Service<{ log: (message: string) => void }>()("Game/Logger") * ``` */ export const Service = (): CheckedValue<"service", Value, ( @@ -580,7 +591,7 @@ const asyncValidationIssue: ReadonlyArray = [ * ```ts * const Position = Descriptor.ConstructedComponent( * Descriptor.fromStandardSchema(type({ x: "number", y: "number" })) - * )("Position") + * )("Game/Position") * ``` */ export const fromStandardSchema = ( diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts index d3106a3..55bd87f 100644 --- a/packages/core/src/Entity.ts +++ b/packages/core/src/Entity.ts @@ -320,7 +320,7 @@ export interface InvalidHandle { * const enemy = Entity.decodeHandle(Root, isRecord(raw) ? raw["enemy"] : undefined, Health) * return enemy.ok ? Result.success({ enemy: enemy.value }) : enemy * } - * })("Target") + * })("Game/Target") * ``` */ export const decodeHandle = < diff --git a/packages/core/src/Inspector.ts b/packages/core/src/Inspector.ts index 1bf208c..cef1169 100644 --- a/packages/core/src/Inspector.ts +++ b/packages/core/src/Inspector.ts @@ -4,7 +4,13 @@ * Inspectors declare the same reads as systems, but they cannot request write * queries, write resources, commands, events, or queued state transitions. * They run on demand through `runtime.inspect(...)` and return a value without - * advancing schedule visibility. + * running a schedule or changing the world. + * + * Like a system, each inspector reads per-reader streams: `added`/`changed` + * filters, `removed`/`despawned` records, and events show what happened since + * that inspector's own previous evaluation, once. Events are not held for + * inspectors, so one evaluated less often than events are dropped reports + * `lagged()` instead. * * An inspector is the one typed read-only view of the world: tests, saves, UI, * `Game.Condition.check(...)` projections, and devtools invariants diff --git a/packages/core/src/Machine.ts b/packages/core/src/Machine.ts index 96a554e..e84ac75 100644 --- a/packages/core/src/Machine.ts +++ b/packages/core/src/Machine.ts @@ -16,7 +16,7 @@ * @example * ```ts * // Model a gameplay phase whose transition timing matters. - * const GameFlow = Machine.StateMachine("GameFlow", ["Menu", "Playing"] as const) + * const GameFlow = Machine.StateMachine("Game/GameFlow", ["Menu", "Playing"] as const) * * // Gate one system so it only runs in the committed playing phase. * const isPlaying = Machine.inState(GameFlow, "Playing") diff --git a/packages/core/src/Query.ts b/packages/core/src/Query.ts index d809511..6c297c6 100644 --- a/packages/core/src/Query.ts +++ b/packages/core/src/Query.ts @@ -32,7 +32,7 @@ * without: [Frozen] * }) * - * const MoveActors = Game.System("MoveActors", { + * const MoveActors = Game.System("Game/MoveActors", { * queries: { moving: MovingActors }, * resources: { dt: Game.System.readResource(DeltaTime) } * }, ({ queries, resources }) => { @@ -535,7 +535,21 @@ export namespace Query { export type SingleError = NoEntitiesError | MultipleEntitiesError } -/** Deep read projection used by every ECS read capability. */ +/** + * Deep read projection used by every ECS read capability: what `get()` + * returns. + * + * Helpers that take values read from cells type their parameter + * `ReadonlyValue`, not `T`, so `get()` results pass in without a cast. + * + * @example + * ```ts + * import type { ReadonlyValue } from "@typeonce/ritmo/Query" + * + * const spawnRate = (tuning: ReadonlyValue) => tuning.waves[0]?.rate ?? 1 + * spawnRate(resources.tuning.get()) + * ``` + */ export type ReadonlyValue = T extends string | number | boolean | bigint | symbol | null | undefined ? T : T extends (...args: ReadonlyArray) => any ? T diff --git a/packages/core/src/Relation.ts b/packages/core/src/Relation.ts index 29ebad9..389cdd0 100644 --- a/packages/core/src/Relation.ts +++ b/packages/core/src/Relation.ts @@ -11,8 +11,8 @@ * @example * ```ts * // Relations are defined with Descriptor.Hierarchy and Descriptor.Relation. - * const { relation: ChildOf } = Descriptor.Hierarchy("ChildOf", "Children") - * const { relation: Targeting } = Descriptor.Relation("Targeting", "TargetedBy") + * const { relation: ChildOf } = Descriptor.Hierarchy("Game/ChildOf", "Game/Children") + * const { relation: Targeting } = Descriptor.Relation("Game/Targeting", "Game/TargetedBy") * ``` * * @module Relation diff --git a/packages/core/src/Runtime.ts b/packages/core/src/Runtime.ts index 31b675e..7c7a472 100644 --- a/packages/core/src/Runtime.ts +++ b/packages/core/src/Runtime.ts @@ -17,10 +17,12 @@ * * @example * ```ts - * // Provide the services systems declared in their specs. + * // Provide the host capabilities systems declared as services. Simulation + * // time comes from `Clock` and randomness from the `Random` resource + * // (`@typeonce/ritmo-math`), never from a service. * const services = Game.Runtime.services( - * Game.Runtime.service(RenderClock, { now: () => performance.now() }), - * Game.Runtime.service(Random, { next: Math.random }) + * Game.Runtime.service(Audio, { play: (sound) => audioHost.play(sound) }), + * Game.Runtime.service(Nodes, nodeRegistry) * ) * * // make always returns a Result. Viewport is a constructed resource, so @@ -28,7 +30,8 @@ * const runtime = Game.Runtime.make({ * services, * resources: { - * viewport: { width: 800, height: 600 } + * viewport: { width: 800, height: 600 }, + * rng: Random.seed(0x5eed) * }, * machines: Game.Runtime.machines( * Game.Runtime.machine(GameFlow, "Boot") @@ -664,7 +667,10 @@ export interface Runtime< */ readonly restore: Snapshot.Gate Result.Result> /** - * Evaluates one read-only projection without advancing schedule visibility. + * Evaluates one read-only projection without running a schedule. Like a + * system, the inspector sees `added`/`changed` matches, removed and + * despawned records, and events published since its own previous + * evaluation, once. */ readonly inspect: { >( @@ -2888,7 +2894,9 @@ const rejectRepeated = (names: ReadonlyArray, kind: "service" | "machine * * This is the runtime-side counterpart to `Game.System.service(...)`. Use it * to assemble the host implementations the game exposes to systems, such as - * clocks, random generators, render bridges, audio sinks, or network clients. + * render bridges, audio sinks, logs, or network clients. Simulation time is + * `Clock` and randomness is the `Random` resource from + * `@typeonce/ritmo-math`; neither is a service. * * The helper keeps the runtime map keyed by service descriptors instead of * ad hoc strings, so the provision site cannot drift from the declaration site. @@ -2900,7 +2908,7 @@ const rejectRepeated = (names: ReadonlyArray, kind: "service" | "machine * // Bundle host capabilities once when building the runtime. * const services = Game.Runtime.services( * Game.Runtime.service(Logger, { log: console.log }), - * Game.Runtime.service(Random, { next: Math.random }) + * Game.Runtime.service(Audio, { play: (sound) => audioHost.play(sound) }) * ) * ``` */ diff --git a/packages/core/src/Schedule.ts b/packages/core/src/Schedule.ts index c992fe0..c5da40d 100644 --- a/packages/core/src/Schedule.ts +++ b/packages/core/src/Schedule.ts @@ -21,6 +21,12 @@ * transition events, and relation failures are kept until every system that * reads them has run, so schedules may be ticked at different rates. * + * A system skipped by its run conditions (`when`) discards the events, + * transition events, and relation failures published meanwhile, but keeps + * its change-detection position: when it runs again, `added`/`changed` + * filters and removed/despawned reads still show everything since its + * previous completed run. Use `pauseWhen` to keep the messages too. + * * @module Schedule * @docGroup runtime */ diff --git a/packages/core/src/Schema.ts b/packages/core/src/Schema.ts index 0761d8e..72cda35 100644 --- a/packages/core/src/Schema.ts +++ b/packages/core/src/Schema.ts @@ -36,9 +36,9 @@ * * @example * ```ts - * const Position = Descriptor.Component<{ x: number; y: number }>()("Position") - * const Velocity = Descriptor.Component<{ x: number; y: number }>()("Velocity") - * const Score = Descriptor.Resource()("Score") + * const Position = Descriptor.Component<{ x: number; y: number }>()("Game/Position") + * const Velocity = Descriptor.Component<{ x: number; y: number }>()("Game/Velocity") + * const Score = Descriptor.Resource()("Game/Score") * * const Core = Schema.fragment({ * components: { Position, Velocity }, @@ -152,6 +152,12 @@ type PairsOf = R extends ComponentRequirement ? Pairs : never * requires. Nothing is ever filled in with a default: the draft must carry * every value. * + * Types cannot see everything (another command applied first in the same + * `applyDeferred()`, erased specs), so the runtime checks again: a command + * that would break a requirement is skipped and traced (the + * `requirement-violated` lint), and `restore` rejects a save whose entity + * misses a required component. + * * Requirements chain (`A` requires `B`, `B` requires `C`) and may name * components of other fragments of the same `Schema.bind`. * @@ -319,7 +325,7 @@ export type ValidateRequires = * * const Target = Descriptor.Component<{ * handle: Entity.Handle - * }>()("Target") + * }>()("Game/Target") * ``` */ export const defineRoot = (name: Name): RootToken => ({ @@ -765,7 +771,7 @@ export namespace Schema { * * @example * ```ts - * const Phase = Game.StateMachine("Phase", ["Setup", "Battle"], { within: [Session, "Playing"] }) + * const Phase = Game.StateMachine("Game/Phase", ["Setup", "Battle"], { within: [Session, "Playing"] }) * ``` */ < diff --git a/packages/core/src/System.ts b/packages/core/src/System.ts index c460fdc..14c66ff 100644 --- a/packages/core/src/System.ts +++ b/packages/core/src/System.ts @@ -18,7 +18,7 @@ * @example * ```ts * // Define one simulation step with explicit world access. - * const Move = Game.System("Move", { + * const Move = Game.System("Game/Move", { * queries: { * moving: Game.Query({ * selection: { @@ -89,7 +89,7 @@ import type { CommandAccess, CommandsApi, NotDeclared } from "./Command.ts" * * @example * ```ts - * const Move = Game.System("Move", { + * const Move = Game.System("Game/Move", { * queries: { * moving: Game.Query({ * selection: { @@ -125,9 +125,12 @@ export interface ServiceRead> { * Declares that a system needs a service from the external runtime environment. * * Use this for host capabilities that should not live in ECS world storage: - * clocks, random generators, render bridges, audio sinks, persistence APIs, - * or network clients. The matching implementation must be supplied when the - * runtime is created with `Game.Runtime.services(...)`. + * render bridges, audio sinks, logs, persistence APIs, or network clients. + * Simulation time is `Clock` and randomness is the `Random` resource from + * `@typeonce/ritmo-math`, not services. The matching implementation must be + * supplied when the runtime is created with `Game.Runtime.services(...)`. + * Service effects (audio, network, storage) are not rolled back when the + * system fails. */ export const service = >( descriptor: D @@ -176,7 +179,7 @@ export const readResource = >( * * @example * ```ts - * const CountUp = Game.System("CountUp", { + * const CountUp = Game.System("Game/CountUp", { * resources: { * score: Game.System.writeResource(Score) * } @@ -221,7 +224,7 @@ export type ResourceChanged> = { * * @example * ```ts - * const SyncHud = Game.System("SyncHud", { + * const SyncHud = Game.System("Game/SyncHud", { * resources: { score: Game.System.changedResource(Score) }, * services: { hud: Game.System.service(Hud) } * }, ({ resources, services }) => { @@ -265,7 +268,10 @@ export type EventWrite> = { * render rate) still receives all of them. Events nobody has read yet are * kept for the current and previous `runtime.tick(...)` call; a reader's * first run sees whatever is still kept. A system skipped by its run - * conditions discards the events published meanwhile. Each stream is capped + * conditions discards the events published meanwhile, but keeps its + * change-detection position: `added`/`changed` filters and + * `removed`/`despawned` reads still show everything since its previous + * completed run when it runs again. Each stream is capped * at `Runtime.streamCapacity` entries; a reader that missed dropped ones sees * `lagged() === true`. * @@ -293,7 +299,7 @@ export const readEvent = >( * * @example * ```ts - * const EmitHit = Game.System("EmitHit", { + * const EmitHit = Game.System("Game/EmitHit", { * events: { * hit: Game.System.writeEvent(Hit) * } @@ -325,7 +331,8 @@ export interface ThisTick { * runs after the reader and a call whose order is not statically known. A * read with no writer in the call compiles (a reader reused in several * ticks); the `event-never-written` lint reports data nobody writes. - * `tryTick` checks the same at runtime. + * `tryTick` checks the same at runtime. Plugin systems with erased specs + * (`RenderSync.system`, `InputCapture.system`) do not count as writers. * * Use it on reads whose correctness depends on running after their writers * (damage applied the tick it is dealt). A plain read is right when last @@ -345,16 +352,16 @@ export interface ThisTick { * * @example * ```ts - * const ApplyDamage = Game.System("ApplyDamage", { + * const ApplyDamage = Game.System("Game/ApplyDamage", { * events: { damage: Game.System.thisTick(Game.System.readEvent(Damage)) } * }, ({ events }) => { ... }) * * runtime.tick(Game.Schedule(DealDamage, ApplyDamage)) // compiles - * runtime.tick(Game.Schedule(ApplyDamage, DealDamage)) // "ApplyDamage needs Damage from this tick, but its writer DealDamage runs after it" + * runtime.tick(Game.Schedule(ApplyDamage, DealDamage)) // "Game/ApplyDamage needs Game/Damage from this tick, but its writer Game/DealDamage runs after it" * - * const AimAtNewest = Game.System("AimAtNewest", { queries: { enemies: Game.System.thisTick(Enemies) } }, ...) + * const AimAtNewest = Game.System("Game/AimAtNewest", { queries: { enemies: Game.System.thisTick(Enemies) } }, ...) * runtime.tick(Game.Schedule(SpawnWave, Game.Schedule.applyDeferred(), AimAtNewest)) // compiles - * runtime.tick(Game.Schedule(SpawnWave, AimAtNewest)) // "AimAtNewest needs this tick's commands for queries.enemies, but SpawnWave queues them with no applyDeferred() in between" + * runtime.tick(Game.Schedule(SpawnWave, AimAtNewest)) // "Game/AimAtNewest needs this tick's commands for queries.enemies, but Game/SpawnWave queues them with no applyDeferred() in between" * ``` */ export const thisTick = < @@ -490,6 +497,9 @@ export interface NextMachineWriteView): Machine.SetResult /** Like `set`, but committing the current value again is skipped (no exit or enter schedules). */ @@ -565,7 +575,7 @@ export interface RelationFailureRead { * * @example * ```ts - * const DestroyRenderNodesSystem = Game.System("DestroyRenderNodes", { + * const DestroyRenderNodesSystem = Game.System("Game/DestroyRenderNodes", { * removed: { * renderables: Game.System.readRemoved(Renderable) * } @@ -601,7 +611,7 @@ export const readRelationFailures = ( * * @example * ```ts - * const DestroyNodesSystem = Game.System("DestroyNodes", { + * const DestroyNodesSystem = Game.System("Game/DestroyNodes", { * despawned: { * entities: Game.System.readDespawned() * } @@ -680,6 +690,10 @@ export type QueryHandle = /** What every query handle has: iteration over its current matches. */ export interface QueryIteration { + /** + * Returns every current match, in spawn order (ascending entity id), so + * iteration is the same on every run and platform. + */ each(): ReadonlyArray> /** * Returns a single match when zero or one entity satisfies the query. @@ -1208,7 +1222,7 @@ export interface SystemDefinition< * * @example * ```ts - * const Spend = Game.System("Spend", { + * const Spend = Game.System("Game/Spend", { * resources: { gold: Game.System.writeResource(Gold) } * }, ({ resources }) => { * if (resources.gold.get() < 10) return Result.failure("NotEnoughGold" as const) @@ -1232,7 +1246,7 @@ export type SystemRun = * * @example * ```ts - * const CountEnemies = Game.System("CountEnemies", { + * const CountEnemies = Game.System("Game/CountEnemies", { * queries: { * enemies: Game.Query({ * selection: { diff --git a/packages/core/test/Concepts.test.ts b/packages/core/test/Concepts.test.ts index f088fec..e6209cd 100644 --- a/packages/core/test/Concepts.test.ts +++ b/packages/core/test/Concepts.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { runConcepts, runLookups } from "../../../examples/concepts.ts" -describe("CONCEPTS.md program", () => { +describe("concepts program", () => { it("shares query access and reads entities and machine state explicitly", () => { expect(runLookups()).toEqual({ found: true, only: true, position: [{ x: 0, y: 0 }], flow: "Playing" }) }) diff --git a/packages/core/test/Recipes.test.ts b/packages/core/test/Recipes.test.ts deleted file mode 100644 index 5b42852..0000000 --- a/packages/core/test/Recipes.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from "vitest" -import { runRecipes } from "../../../examples/recipes.ts" - -describe("GAME_API.md recipes", () => { - it("replays simulation time and random state from a snapshot", () => { - const { after, replay } = runRecipes() - expect(after).toEqual(replay) - expect(after.resources).toEqual({ "Recipes/Time": { step: 1, elapsed: 1 / 60, dt: 1 / 60 }, "Recipes/Random": 1083814273 }) - }) -}) diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index 6e7d963..a24c4a9 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -24,7 +24,7 @@ - **`Session.run` stops with `invariant-threw`** when an invariant throws, naming the invariant and the error, instead of throwing out of `run` and losing the run's text. - The `Snapshot` docs now say that unread `runtime.send` events survive a restore, as they always did. -- 330246e: **Safety follow-ups from a second review.** Each closes a way a game could go wrong silently; see [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 330246e: **Safety follow-ups from a second review.** Each closes a way a game could go wrong silently; see Upgrading under 0.9.0 in the `@typeonce/ritmo` CHANGELOG.md. - **A `SpawnedId` is valid only in the run that spawned it.** Kept in a closure or resource and passed to `commands.despawn` / `commands.remove` in a later run, it could name an entity despawned meanwhile whose id was reused. Such a command is now skipped, traced as a `staleTarget` effect, and reported by the new `stale-target` lint (`Debug.StaleTarget`). Keep the entity id or a handle and resolve it through a query. - **`any` and `never` are not descriptor values.** `Descriptor.Component()`, `Resource`, `Event`, `Service`, and the transient variants fail at the constructor: `any` turned off every check on the descriptor, and `never` erased the fragment listing it. @@ -58,7 +58,7 @@ - `RenderSync.system` now builds one query per sync system (an `anyOf` of its added and changed filters) instead of one query per trigger plus deduplication. - `Debug.QueryDescription` gains `anyOf` (one entry per `anyOf` filter, with its `added` and `changed` components); code that builds a `QueryDescription` by hand must add it. The text report prints it as `any of (...)`, and Monitor counts its components as reads. -- 0b22113: **Breaking: required components; `insert` and `remove` take a query match.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-080). +- 0b22113: **Breaking: required components; `insert` and `remove` take a query match.** See Upgrading under 0.8.0 in the `@typeonce/ritmo` CHANGELOG.md. A fragment declares what always comes together: `Schema.fragment({ components: { ... }, requires: [Schema.requires(Player, Position, Velocity)] })`. Requirements chain, may name components of other fragments of the same `Schema.bind` (checked there), and are never filled in with defaults. diff --git a/packages/devtools/README.md b/packages/devtools/README.md index ddb81af..e05a172 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -1,7 +1,5 @@ # `@typeonce/ritmo-devtools` -Migrating from bevy-ts? See the [package migration guide](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md). - `Determinism.check` compares the same scripted simulation twice, including transient components that snapshots omit: @@ -44,12 +42,13 @@ if (runtime.debug !== undefined) Monitor.attach({ debug: runtime.debug }, { sche ## The debugging loop -1. **Get a headless runtime.** Build the game's simulation schedules on a - runtime that needs no renderer, with `debug: { hostClock: HostClock.test() }` - and scripted input. The - top-down example's [`simulation.ts`](../../examples/top-down/simulation.ts) - is the reference; its [`debug.ts`](../../examples/top-down/debug.ts) is a - script to copy. +1. **Get a headless runtime.** Keep the game's simulation (schema, systems, + and the schedules ticked at the fixed step) in modules that import no + renderer or page API. Build those schedules on a runtime made with + `debug: { hostClock: HostClock.test() }`, and feed input from + `Keyboard.scripted(bindings, timeline)` (from `@typeonce/ritmo-browser`) + instead of the page. Put that in a script that makes the runtime, the + `Session`, and the run (see the cheat sheet below), and run it with Node. 2. **Read the lints, then orient.** `session.describe()` starts with lints (events nobody reads, next states no marker applies, relations whose failures nobody reads, ...); a warning there is often the whole answer. Then it lists every schedule step, each @@ -75,7 +74,7 @@ if (runtime.debug !== undefined) Monitor.attach({ debug: runtime.debug }, { sche to the game so the bug stays fixed. ```sh -node --import tsx examples/top-down/debug.ts +node --import tsx debug.ts ``` ## Cheat sheet @@ -206,6 +205,31 @@ data without DOM, for a custom UI. The report also starts with the static lints from `describe()`. +## Lint from the command line + +`ritmo-devtools lint ` is the pass/fail check for a game's `check` +script. `` exports `lintTarget` (see the `Lint` module): it prints +every lint of the schedules with its fix and exits with 1 on a warning. + +```sh +ritmo-devtools lint src/simulation.ts +``` + +It also checks that the oxlint simulation rules from +`@typeonce/oxlint-plugin-ritmo` cover the simulation. Every module that +importing `` loads, inside the directory of the `.oxlintrc.json` in the +working directory and outside `node_modules`, must have +`ritmo/keep-simulation-headless` on there (overrides apply in order, the last +match wins; `ignorePatterns` matches are uncovered). Modules imported inside +`lintTarget` or later (render schedules passed to `describe`) are host code +and are not checked, nor are type-only imports. A missing config, one with +`extends`, or one that never turns the rule on prints why the check was +skipped and does not fail. The check needs Node 22.15 or later. + +| Code | Meaning | +|---|---| +| `uncovered-simulation-module` | The simulation loads this file, but no simulation override in `.oxlintrc.json` covers it, so oxlint never checks it for platform, time, randomness, or math calls. Add it to the override's `files`, or stop importing values from it. Exits with 1. | + The trace shows what systems wrote, not what they read. When a system writes a wrong value from correct inputs, the journal narrows the bug to that system (it runs, in order, and writes this value) and the rest is its code. diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 101308f..860a145 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -27,6 +27,7 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "bin": { @@ -73,6 +74,6 @@ "@typeonce/ritmo": "workspace:^" }, "engines": { - "node": ">=20" + "node": ">=22.15" } } diff --git a/packages/devtools/src/cli.ts b/packages/devtools/src/cli.ts index 3e232a7..6b313cb 100755 --- a/packages/devtools/src/cli.ts +++ b/packages/devtools/src/cli.ts @@ -8,12 +8,28 @@ * prints every lint of its schedules with its fix, and exits with 1 when one * is a warning. Info-level lints are printed and do not fail. * + * It also checks simulation coverage: every module that importing `` + * loads (inside the directory of `.oxlintrc.json` in the working directory, + * outside `node_modules`) must have `ritmo/keep-simulation-headless` on in that + * config, so the oxlint simulation rules check it. Each one that does not is + * printed as `uncovered-simulation-module` and exits with 1. Only modules + * loaded by importing the entry are checked: modules imported inside + * `lintTarget` (such as render schedules passed to `describe`) or loaded later + * are host code and are not. Type-only imports never load and are not checked. + * A missing or unreadable config, one with `extends`, or one that never turns + * the rule on prints why the check was skipped and does not fail. + * + * Exit codes: 0 ok, 1 lint warning or uncovered module, 2 usage error. + * Requires Node 22.15 or later for `module.registerHooks`; on older Node it + * prints that coverage was not checked. + * * @module cli * @docGroup devtools */ import { resolve } from "node:path" import { pathToFileURL } from "node:url" +import * as SimulationCoverage from "./internal/simulationCoverage.ts" import * as Lint from "./Lint.ts" const usage = "usage: ritmo-devtools lint \n exports lintTarget: () => { runtime, schedules, describe?, hostEvents? } (see the Lint module)" @@ -36,7 +52,10 @@ const main = async (args: ReadonlyArray): Promise => { console.error(usage) return 2 } + const loads = SimulationCoverage.recordLoads() const loaded: unknown = await import(pathToFileURL(resolve(entry)).href) + // Snapshot before lintTarget runs: modules it imports (render schedules to describe) are host code. + const simulationModules = loads?.() const make = isRecord(loaded) ? loaded["lintTarget"] : undefined if (typeof make !== "function") { console.error(`${entry} must export lintTarget: a function returning { runtime, schedules, describe? } (see the Lint module)`) @@ -49,7 +68,14 @@ const main = async (args: ReadonlyArray): Promise => { } const report = Lint.run(target) console.log(report.text) - return report.data.ok ? 0 : 1 + if (simulationModules === undefined) { + console.log("simulation coverage not checked: needs Node 22.15 or later (module.registerHooks)") + return report.data.ok ? 0 : 1 + } + const coverage = SimulationCoverage.check(process.cwd(), resolve(entry), simulationModules) + const text = SimulationCoverage.format(coverage) + if (text !== "") console.log(text) + return report.data.ok && (!coverage.checked || coverage.uncovered.length === 0) ? 0 : 1 } process.exitCode = await main(process.argv.slice(2)) diff --git a/packages/devtools/src/internal/simulationCoverage.ts b/packages/devtools/src/internal/simulationCoverage.ts new file mode 100644 index 0000000..915af42 --- /dev/null +++ b/packages/devtools/src/internal/simulationCoverage.ts @@ -0,0 +1,136 @@ +/** + * Simulation coverage for the `ritmo-devtools lint` CLI (Node only): which + * modules the simulation loads that the oxlint simulation rules + * (`ritmo/keep-simulation-headless` and its neighbours) do not cover. + * + * @internal + */ +import { readFileSync, realpathSync } from "node:fs" +import * as Module from "node:module" +import { dirname, isAbsolute, join, matchesGlob, relative, sep } from "node:path" +import { fileURLToPath } from "node:url" + +const configName = ".oxlintrc.json" +const rule = "ritmo/keep-simulation-headless" + +/** Starts recording every `file:` module Node loads from now on. `undefined` when this Node has no `module.registerHooks` (before 22.15). */ +export const recordLoads = (): (() => ReadonlyArray) | undefined => { + if (typeof Module.registerHooks !== "function") return undefined + const urls = new Set() + Module.registerHooks({ + load: (url, context, nextLoad) => { + if (url.startsWith("file:")) urls.add(url) + return nextLoad(url, context) + } + }) + return () => [...urls] +} + +export type Coverage = + | { readonly checked: false; readonly reason: string } + | { readonly checked: true; readonly uncovered: ReadonlyArray } + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** Removes `//` and `/* *\/` comments outside strings, then trailing commas before `}` or `]`. */ +const stripJsonc = (text: string): string => { + let out = "" + let index = 0 + while (index < text.length) { + const char = text[index]! + if (char === "\"") { + let end = index + 1 + while (end < text.length && text[end] !== "\"") end += text[end] === "\\" ? 2 : 1 + out += text.slice(index, end + 1) + index = end + 1 + } else if (char === "/" && text[index + 1] === "/") { + while (index < text.length && text[index] !== "\n") index++ + } else if (char === "/" && text[index + 1] === "*") { + const end = text.indexOf("*/", index + 2) + index = end === -1 ? text.length : end + 2 + } else { + out += char + index++ + } + } + return out.replace(/("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, (match, string: string | undefined, close: string | undefined) => + string !== undefined ? match : close!) +} + +/** `true` on, `false` off, `undefined` when the value does not set the rule. */ +const severity = (value: unknown): boolean | undefined => { + const level = Array.isArray(value) ? value[0] : value + if (level === "error" || level === "warn" || level === 1 || level === 2) return true + if (level === "off" || level === 0) return false + return undefined +} + +const globs = (value: unknown): ReadonlyArray => + Array.isArray(value) ? value.filter((glob): glob is string => typeof glob === "string") : [] + +const matches = (file: string, patterns: ReadonlyArray): boolean => + patterns.some((pattern) => matchesGlob(file, pattern)) + +/** + * Reads `.oxlintrc.json` in `cwd` and returns the loaded modules inside its + * directory (outside `node_modules`, not the entry) where `ritmo/keep-simulation-headless` + * is not on, as posix paths relative to the config, sorted. + */ +export const check = (cwd: string, entry: string, loaded: ReadonlyArray): Coverage => { + const configPath = join(cwd, configName) + let text: string + try { + text = readFileSync(configPath, "utf8") + } catch { + return { checked: false, reason: `no ${configName} in ${cwd}` } + } + let config: unknown + try { + config = JSON.parse(stripJsonc(text)) + } catch { + return { checked: false, reason: `${configName} is not valid JSON with comments` } + } + if (!isRecord(config)) return { checked: false, reason: `${configName} is not an object` } + if (config["extends"] !== undefined) return { checked: false, reason: `${configName} uses "extends", which this check does not follow` } + const topLevel = isRecord(config["rules"]) ? severity(config["rules"][rule]) : undefined + const overrides = (Array.isArray(config["overrides"]) ? config["overrides"] : []).filter(isRecord).map((override) => ({ + files: globs(override["files"]), + setting: isRecord(override["rules"]) ? severity(override["rules"][rule]) : undefined + })) + if (topLevel !== true && !overrides.some((override) => override.setting === true)) { + return { checked: false, reason: `no rules in ${configName} turn on ${rule}` } + } + const ignored = globs(config["ignorePatterns"]) + const root = realpathSync(dirname(configPath)) + const entryPath = realpathSync(entry) + const uncovered = new Set() + for (const url of loaded) { + let file: string + try { + file = realpathSync(fileURLToPath(url)) + } catch { + continue + } + const path = relative(root, file) + if (file === entryPath || path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) continue + const posix = path.split(sep).join("/") + if (posix.split("/").includes("node_modules")) continue + let on = topLevel === true + for (const override of overrides) { + if (override.setting !== undefined && matches(posix, override.files)) on = override.setting + } + if (!on || matches(posix, ignored)) uncovered.add(posix) + } + return { checked: true, uncovered: [...uncovered].sort() } +} + +/** The CLI text for a coverage result: a "not checked" line, or one block per uncovered module. */ +export const format = (coverage: Coverage): string => + !coverage.checked + ? `simulation coverage not checked: ${coverage.reason}` + : coverage.uncovered.map((file) => + `uncovered-simulation-module ${file}\n` + + ` The simulation loads this file, but the simulation rules in ${configName} do not cover it.\n` + + ` Add it to the simulation override's "files", or stop importing values from it.` + ).join("\n") diff --git a/packages/devtools/test/SimulationCoverage.test.ts b/packages/devtools/test/SimulationCoverage.test.ts new file mode 100644 index 0000000..79d2614 --- /dev/null +++ b/packages/devtools/test/SimulationCoverage.test.ts @@ -0,0 +1,145 @@ +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { afterEach, describe, expect, it } from "vitest" + +const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url)) +const fixture = new URL("./fixtures/lint-entry.ts", import.meta.url).href + +const simulationRules = { "ritmo/keep-simulation-headless": "error", "ritmo/use-clock-for-time": "error" } +const config = (overrides: ReadonlyArray, extra: Record = {}): string => + JSON.stringify({ jsPlugins: ["@typeonce/oxlint-plugin-ritmo"], overrides, ...extra }) + +const dirs: Array = [] +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + +/** Writes `files` into a fresh project, with `main.ts` (unless given) re-exporting the fixture's lintTarget, and runs the CLI in it. */ +const lintProject = (files: Record, entryBody = ""): { readonly status: number; readonly output: string } => { + const dir = mkdtempSync(join(tmpdir(), "ritmo-coverage-")) + dirs.push(dir) + const all = { "main.ts": `export { lintTarget } from ${JSON.stringify(fixture)}\n${entryBody}`, ...files } + for (const [name, content] of Object.entries(all)) { + mkdirSync(dirname(join(dir, name)), { recursive: true }) + writeFileSync(join(dir, name), content) + } + try { + const output = execFileSync(process.execPath, [cli, "lint", "main.ts"], { + cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LINT_QUIET: "1" } + }) + return { status: 0, output } + } catch (error) { + const failed = error instanceof Error && "status" in error && "stdout" in error ? error : undefined + return { status: typeof failed?.status === "number" ? failed.status : -1, output: typeof failed?.stdout === "string" ? failed.stdout : "" } + } +} + +const uncovered = (output: string): ReadonlyArray => + [...output.matchAll(/^uncovered-simulation-module {2}(\S+)$/gm)].map((match) => match[1]!) + +describe("ritmo-devtools lint: simulation coverage", () => { + it("reports a value import outside the simulation override", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([{ files: ["src/sim/**"], rules: simulationRules }]), + "src/sim/move.ts": "export const speed = 1\n", + "src/ui.ts": "export const label = \"hp\"\n" + }, "import \"./src/sim/move.ts\"\nimport { label } from \"./src/ui.ts\"\nexport const used = label\n") + expect(status).toBe(1) + expect(uncovered(output)).toEqual(["src/ui.ts"]) + expect(output).toContain([ + "uncovered-simulation-module src/ui.ts", + " The simulation loads this file, but the simulation rules in .oxlintrc.json do not cover it.", + " Add it to the simulation override's \"files\", or stop importing values from it." + ].join("\n")) + }) + + it("passes when every loaded module is covered, and skips the entry and files outside the config directory", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([{ files: ["src/**"], rules: simulationRules }]), + "src/a.ts": "import { b } from \"./nested/b.ts\"\nexport const a = b\n", + "src/nested/b.ts": "export const b = 1\n" + }, "import { a } from \"./src/a.ts\"\nexport const used = a\n") + expect(uncovered(output)).toEqual([]) + expect(output).not.toContain("not checked") + expect(status).toBe(0) + }) + + it("does not report a type-only import", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([{ files: ["src/sim/**"], rules: simulationRules }]), + "src/types.ts": "export type Hp = number\nexport const unused = 0\n" + }, "import type { Hp } from \"./src/types.ts\"\nexport const hp: Hp = 1\n") + expect(uncovered(output)).toEqual([]) + expect(status).toBe(0) + }) + + it("lets a later override turn the rule off", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([ + { files: ["src/**"], rules: simulationRules }, + { files: ["src/render*.ts"], rules: { "ritmo/keep-simulation-headless": "off" } } + ]), + "src/move.ts": "export const speed = 1\n", + "src/renderHud.ts": "export const hud = 1\n" + }, "import \"./src/move.ts\"\nimport \"./src/renderHud.ts\"\n") + expect(uncovered(output)).toEqual(["src/renderHud.ts"]) + expect(status).toBe(1) + }) + + it("reports a module matched by ignorePatterns", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([{ files: ["src/**"], rules: simulationRules }], { ignorePatterns: ["src/generated/**"] }), + "src/generated/table.ts": "export const table = [1]\n" + }, "import \"./src/generated/table.ts\"\n") + expect(uncovered(output)).toEqual(["src/generated/table.ts"]) + expect(status).toBe(1) + }) + + it("does not report a module imported only inside lintTarget", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([{ files: ["src/sim/**"], rules: simulationRules }]), + "main.ts": [ + `import { lintTarget as base } from ${JSON.stringify(fixture)}`, + "export const lintTarget = async () => {", + " await import(\"./src/present.ts\")", + " return base()", + "}" + ].join("\n"), + "src/present.ts": "export const draw = 1\n" + }) + expect(status).toBe(0) + expect(uncovered(output)).toEqual([]) + }) + + it("parses comments and trailing commas", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": [ + "{", + " // simulation", + " \"overrides\": [", + " /* the game's systems */", + " { \"files\": [\"src/sim/**\", \"src/a//b.ts\",], \"rules\": { \"ritmo/keep-simulation-headless\": [\"error\", {},], }, },", + " ],", + "}" + ].join("\n"), + "src/sim/move.ts": "export const speed = 1\n", + "src/ui.ts": "export const label = 1\n" + }, "import \"./src/sim/move.ts\"\nimport \"./src/ui.ts\"\n") + expect(output).not.toContain("not checked") + expect(uncovered(output)).toEqual(["src/ui.ts"]) + expect(status).toBe(1) + }) + + it("prints that coverage was not checked when no override turns the simulation rules on", () => { + const { status, output } = lintProject({ + ".oxlintrc.json": config([{ files: ["src/**"], rules: { "ritmo/use-cell-value": "error" } }]), + "src/ui.ts": "export const label = 1\n" + }, "import \"./src/ui.ts\"\n") + expect(output).toContain("simulation coverage not checked: no rules in .oxlintrc.json turn on ritmo/keep-simulation-headless") + expect(uncovered(output)).toEqual([]) + expect(status).toBe(0) + }) +}) diff --git a/packages/math/CHANGELOG.md b/packages/math/CHANGELOG.md index 0bddc32..67ec386 100644 --- a/packages/math/CHANGELOG.md +++ b/packages/math/CHANGELOG.md @@ -28,7 +28,7 @@ ### Patch Changes -- 9cca78b: **One shape for `Runtime.make`, and runtime guards from a review of 0.8.0.** See [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 9cca78b: **One shape for `Runtime.make`, and runtime guards from a review of 0.8.0.** See Upgrading under 0.9.0 in the `@typeonce/ritmo` CHANGELOG.md. - **Breaking: `Runtime.make` always returns a `Result`.** It returned the bare runtime or a `Result` depending on the resource values present at runtime, while its type decided from the keys of the provided type, so resources typed `{ Score?: ... }` produced a bare runtime the type called a `Result`. The type is now `Success` when nothing provided can fail (read `.value`), and a `Result` to check when a constructed resource may be provided. - **`thisTick` checks a writer that shares its reader's display name.** Systems may share a display name; the static check skipped any writer named like the reader. It now tells the reader apart by its system type. diff --git a/packages/math/README.md b/packages/math/README.md index 8eae7dc..c032e7a 100644 --- a/packages/math/README.md +++ b/packages/math/README.md @@ -1,7 +1,5 @@ # `@typeonce/ritmo-math` -Migrating from bevy-ts? See the [package migration guide](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md). - Math for ritmo games, in two kinds: - **Validated values**: `Scalar`, `Vector2`, `Size2`, `Aabb`, and `InputAxis`. Every one exports constructors that validate untrusted input, so they work as `Descriptor.ConstructedComponent(...)` constructors and survive snapshot restore. @@ -23,7 +21,7 @@ pnpm add @typeonce/ritmo-math @typeonce/ritmo ## Determinism across devices -JavaScript engines may approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run, so a session recorded on a phone stops replaying on a server. `Ops` has the same functions as pure-JavaScript ports of fdlibm, built only from operations every engine rounds exactly, so they return the same bits everywhere (within an ulp of the exact value). Use them in simulation code; `Math.abs`, `min`, `max`, `floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `+ - * /`, and `x ** 2` are already exact. `Vec2`, `Collide2`, and `FlowField` use `Ops` themselves, and the `ritmo/use-ops-for-math` rule of [`@typeonce/oxlint-plugin-ritmo`](../oxlint-plugin/README.md) reports the `Math` calls left in simulation code. +JavaScript engines may approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run, so a session recorded on a phone stops replaying on a server. `Ops` has the same functions as pure-JavaScript ports of fdlibm, built only from operations every engine rounds exactly, so they return the same bits everywhere (within an ulp of the exact value). Use them in simulation code; `Math.abs`, `min`, `max`, `floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `+ - * /`, and `x ** 2` are already exact. `Vec2`, `Collide2`, and `FlowField` use `Ops` themselves, and the `ritmo/use-ops-for-math` rule of [`@typeonce/oxlint-plugin-ritmo`](https://www.npmjs.com/package/@typeonce/oxlint-plugin-ritmo) reports the `Math` calls left in simulation code. ```ts import { Ops } from "@typeonce/ritmo-math" diff --git a/packages/math/package.json b/packages/math/package.json index 098ddcd..a882e86 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -26,6 +26,7 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "exports": { diff --git a/packages/math/src/Random.ts b/packages/math/src/Random.ts index 4c2d8aa..3452440 100644 --- a/packages/math/src/Random.ts +++ b/packages/math/src/Random.ts @@ -14,7 +14,7 @@ * const Rng = Descriptor.ConstructedResource(Random)("Game/Rng") * const runtime = Game.Runtime.make({ services, resources: { Rng: Random.seed(0x5eed) } }) * - * Game.System("Spawn", { resources: { rng: Game.System.writeResource(Rng) } }, ({ resources }) => { + * Game.System("Game/Spawn", { resources: { rng: Game.System.writeResource(Rng) } }, ({ resources }) => { * const random = Random.draw(resources.rng) * const angle = random.range(0, Math.PI * 2) * const kind = random.pick(kinds) ?? "grunt" diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index bd85b79..3b5d095 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -18,7 +18,7 @@ Released together with [`@typeonce/ritmo`](https://www.npmjs.com/package/@typeon ## Setup -Register the plugin and turn the rules on in `.oxlintrc.json`. The simulation rules go in an `overrides` entry that lists the game's simulation files: the code its fixed-step schedules run, which must replay exactly and run headless. Host, render, UI, tool, and unrelated files are not listed, so they keep using the platform freely. +Register the plugin and turn the rules on in `.oxlintrc.json`. The simulation rules go in an `overrides` entry that lists the game's simulation files: the code its fixed-step schedules run, which must replay exactly and run headless. Host, render, UI, tool, and unrelated files are not listed, so they keep using the platform freely. A file the override does not list gets no simulation checks, so list folders rather than single files, and run `ritmo-devtools lint` from `@typeonce/ritmo-devtools`: it reports every module the simulation loads that the override misses. ```jsonc { @@ -100,7 +100,7 @@ Catches `Math.random()`, `crypto.getRandomValues()`, and `crypto.randomUUID()`. ### `ritmo/use-ops-for-math` -The spec lets engines approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run. [`Ops`](../math/README.md#determinism-across-devices) from `@typeonce/ritmo-math` has each of them as a port that returns the same bits everywhere. +The spec lets engines approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run. [`Ops`](https://www.npmjs.com/package/@typeonce/ritmo-math#determinism-across-devices) from `@typeonce/ritmo-math` has each of them as a port that returns the same bits everywhere. ```ts const turn = Math.atan2(Math.sin(b - a), Math.cos(b - a)) // reported diff --git a/packages/oxlint-plugin/package.json b/packages/oxlint-plugin/package.json index b427584..127bd01 100644 --- a/packages/oxlint-plugin/package.json +++ b/packages/oxlint-plugin/package.json @@ -27,6 +27,7 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "exports": { diff --git a/packages/pixi/README.md b/packages/pixi/README.md index a73d724..5088d40 100644 --- a/packages/pixi/README.md +++ b/packages/pixi/README.md @@ -1,7 +1,5 @@ # `@typeonce/ritmo-pixi` -Migrating from bevy-ts? See the [package migration guide](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md). - Pixi's implementation of the renderer-independent `NodeRegistry` contract. `PixiNodeRegistry.make` attaches nodes to one Pixi container and detaches and destroys them with their children on removal. Import the generic `NodeRegistry` contract and `RenderSync` systems from [`@typeonce/ritmo-render`](https://www.npmjs.com/package/@typeonce/ritmo-render). The Pixi package exports only its adapter; systems depend on the generic registry service, and the host chooses its implementation. diff --git a/packages/pixi/package.json b/packages/pixi/package.json index 3c85791..4dba0a2 100644 --- a/packages/pixi/package.json +++ b/packages/pixi/package.json @@ -27,6 +27,7 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "exports": { diff --git a/packages/render/CHANGELOG.md b/packages/render/CHANGELOG.md index 66ffa22..ce38251 100644 --- a/packages/render/CHANGELOG.md +++ b/packages/render/CHANGELOG.md @@ -4,7 +4,7 @@ ### Patch Changes -- 330246e: **Safety follow-ups from a second review.** Each closes a way a game could go wrong silently; see [MIGRATION.md](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md#upgrading-to-090). +- 330246e: **Safety follow-ups from a second review.** Each closes a way a game could go wrong silently; see Upgrading under 0.9.0 in the `@typeonce/ritmo` CHANGELOG.md. - **A `SpawnedId` is valid only in the run that spawned it.** Kept in a closure or resource and passed to `commands.despawn` / `commands.remove` in a later run, it could name an entity despawned meanwhile whose id was reused. Such a command is now skipped, traced as a `staleTarget` effect, and reported by the new `stale-target` lint (`Debug.StaleTarget`). Keep the entity id or a handle and resolve it through a query. - **`any` and `never` are not descriptor values.** `Descriptor.Component()`, `Resource`, `Event`, `Service`, and the transient variants fail at the constructor: `any` turned off every check on the descriptor, and `never` erased the fragment listing it. diff --git a/packages/render/README.md b/packages/render/README.md index 1fead5c..0bb0e26 100644 --- a/packages/render/README.md +++ b/packages/render/README.md @@ -1,7 +1,5 @@ # `@typeonce/ritmo-render` -Migrating from bevy-ts? See the [package migration guide](https://github.com/typeonce-dev/ritmo/blob/main/MIGRATION.md). - Renderer-agnostic entity-to-node mirroring for ritmo: a render-node registry keyed by entity (`NodeRegistry`), a system that creates, updates, and destroys renderer nodes from ECS entities (`RenderSync.system`), and a system that draws fixed-step movement smoothly between steps (`RenderSync.interpolate`). ```sh diff --git a/packages/render/package.json b/packages/render/package.json index b921e54..679ab02 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -25,6 +25,7 @@ "src/**/*.ts", "dist", "README.md", + "CHANGELOG.md", "LICENSE" ], "exports": { diff --git a/packages/render/src/RenderSync.ts b/packages/render/src/RenderSync.ts index a09ee7f..fab3e45 100644 --- a/packages/render/src/RenderSync.ts +++ b/packages/render/src/RenderSync.ts @@ -26,6 +26,9 @@ * services declared in `services`. Those resources and services become * runtime requirements like the registry itself. * + * Its spec is erased, so it does not count as a writer for + * `Game.System.thisTick(...)` order checks. + * * @module RenderSync * @docGroup render * diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index a8a1e3a..a7d7c71 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -46,12 +46,15 @@ for (const directory of packages) { if (created === undefined) throw new Error(`pnpm pack produced no tarball for ${manifest.name}`) tarballByName.set(manifest.name, path.join(tarballs, created)) const listing = run("tar", ["-tzf", path.join(tarballs, created)], work).split("\n") - for (const required of ["package/LICENSE", "package/README.md", "package/dist/index.js", "package/dist/index.d.ts", "package/src/index.ts"]) { + for (const required of ["package/LICENSE", "package/README.md", "package/CHANGELOG.md", "package/dist/index.js", "package/dist/index.d.ts", "package/src/index.ts"]) { if (!listing.includes(required)) throw new Error(`${manifest.name} tarball is missing ${required}`) } if (listing.some((file) => file.includes("/test/") || file.includes("tsbuildinfo"))) { throw new Error(`${manifest.name} tarball contains test or build-info files`) } + if (directory === "core" && !listing.includes("package/skills/ritmo/SKILL.md")) { + throw new Error(`${manifest.name} tarball is missing the agent skill package/skills/ritmo/SKILL.md`) + } if (directory === "pixi" && listing.some((file) => /\/(NodeRegistry|RenderSync)\.(ts|js|d\.ts)$/.test(file))) { throw new Error("Pixi tarball contains removed compatibility modules") } From b719f6fdecf7cf5f696078242db1ceb93a8ed7d9 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 19 Oct 2026 11:28:25 +0900 Subject: [PATCH 2/6] Format the README for skimming, and make the skill install agent-neutral Co-Authored-By: Claude Opus 5.5 --- .../agent-docs-and-simulation-coverage.md | 2 +- README.md | 150 +++++++++++------- packages/core/README.md | 2 +- 3 files changed, 97 insertions(+), 57 deletions(-) diff --git a/.changeset/agent-docs-and-simulation-coverage.md b/.changeset/agent-docs-and-simulation-coverage.md index 7223f55..8631ba0 100644 --- a/.changeset/agent-docs-and-simulation-coverage.md +++ b/.changeset/agent-docs-and-simulation-coverage.md @@ -11,7 +11,7 @@ **Everything a game's agent needs now ships with the packages, and `ritmo-devtools lint` reports simulation modules the lint rules miss.** - **Breaking: `ritmo-devtools lint` fails on an uncovered simulation module.** Every module the entry loads that the `ritmo/keep-simulation-headless` override in `.oxlintrc.json` does not cover is reported as `uncovered-simulation-module`, and the command exits 1. Add the module (or its folder) to the simulation override's `"files"`, or stop importing values from it. Modules loaded only inside `lintTarget` are not checked. Requires Node 22.15 or newer (`engines.node` is now `>=22.15`). -- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: where the docs are, and principles for where a game's code and state belong. Link it into a game with `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .claude/skills/ritmo` (Codex: `.agents/skills/`). +- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. - **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 moved from the repository's `MIGRATION.md` into the `@typeonce/ritmo` changelog. - **JSDoc corrections.** Examples no longer provide randomness or time as services (use the `Random` resource and `Clock`), the `Command` module example declares its commands, inspector docs state that `added`/`changed` reads report records since the inspector's previous evaluation, and every example uses prefixed names (`"Game/Position"`). Guidance that lived only in the repository's guides (spawn order, `ReadonlyValue` helpers, re-entering a state, `thisTick` and plugin systems, service effects and rollback) is now in the JSDoc. - Package READMEs link other packages by npm URL, so the links work on npm. diff --git a/README.md b/README.md index 990215c..a5fbff2 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,18 @@ # Ritmo -The agent-first type safe ECS runtime +**The agent-first, type-safe ECS runtime for games.** -You direct the mechanics and feel. Your coding agent builds the simulation with -closed schemas, declared system access, explicit schedule boundaries, and typed -services. Compile-time checks reject undeclared access and missing requirements; -headless sessions expose lints, invariants, journals, and the causes of changes. +🎮 You direct the mechanics and feel.
+🤖 Your coding agent writes the simulation.
+🛡️ Ritmo keeps the agent on track: -Ritmo works with your renderer and game loop. Its packages provide simulation, -input, timing, math, and renderer bridges. Drawing stays in the game. +- ✅ **Wrong code does not compile.** Undeclared access and missing + requirements are type errors, and each error names the fix. +- 🔍 **Every run can be inspected.** Headless sessions show lints, + invariants, journals, and why each value changed. +- 🎨 **Your renderer, your loop.** Ritmo brings simulation, input, timing, + math, and renderer bridges. Drawing stays in your game.

@@ -30,43 +33,67 @@ input, timing, math, and renderer bridges. Drawing stays in the game. pnpm add @typeonce/ritmo ``` -Requires TypeScript 5.9 or newer. The type tests run on every TypeScript -version from 5.9 up, and the packages build with TypeScript 7. - -Leave `noErrorTruncation` off in your tsconfig. A rejected call's error names -the fix near its start, and the type tests check that every fix stays visible -in TypeScript's default (truncated) message; with truncation off, an error that -prints a whole schema can run to tens of kilobytes. +- Requires TypeScript 5.9 or newer (tested on every version from 5.9 up). +- Leave `noErrorTruncation` off: errors name the fix near their start, and + an untruncated error can print a whole schema. ```ts import { Clock, Descriptor, Schema } from "@typeonce/ritmo" +type Vec = { x: number; y: number } + // Define the world once: components, resources, and the fixed step. const step = Clock.hz(60) const Time = Clock.resource("Example/Time", step) -const Position = Descriptor.Component<{ x: number; y: number }>()("Example/Position") -const Velocity = Descriptor.Component<{ x: number; y: number }>()("Example/Velocity") +const Position = Descriptor.Component()("Example/Position") +const Velocity = Descriptor.Component()("Example/Velocity") // Bind the schema to its game-facing APIs. -const Game = Schema.bind(Schema.fragment({ components: { Position, Velocity }, resources: { Time } })) - -const AdvanceTime = Clock.system(Game, { name: "Example/AdvanceTime", resource: Time }) +const Game = Schema.bind( + Schema.fragment({ + components: { Position, Velocity }, + resources: { Time } + }) +) + +const AdvanceTime = Clock.system(Game, { + name: "Example/AdvanceTime", + resource: Time +}) // Systems receive only the access they declare. -const Move = Game.System("Example/Move", { - queries: { moving: Game.Query({ selection: { position: Game.Query.write(Position), velocity: Game.Query.read(Velocity) } }) }, - resources: { time: Game.System.readResource(Time) } -}, ({ queries, resources }) => { - const { dt } = resources.time.get() - for (const match of queries.moving.each()) { - const position = match.data.position.get() - const velocity = match.data.velocity.get() - match.data.position.set({ x: position.x + velocity.x * dt, y: position.y + velocity.y * dt }) +const Move = Game.System( + "Example/Move", + { + queries: { + moving: Game.Query({ + selection: { + position: Game.Query.write(Position), + velocity: Game.Query.read(Velocity) + } + }) + }, + resources: { time: Game.System.readResource(Time) } + }, + ({ queries, resources }) => { + const { dt } = resources.time.get() + for (const match of queries.moving.each()) { + const position = match.data.position.get() + const velocity = match.data.velocity.get() + match.data.position.set({ + x: position.x + velocity.x * dt, + y: position.y + velocity.y * dt + }) + } } -}) +) -// Starting can fail (the clock is a validated resource), and so can a tick: both return a Result. -const made = Game.Runtime.make({ services: Game.Runtime.services(), resources: { Time: Clock.start(step) } }) +// Starting can fail (the clock is a validated resource), +// and so can a tick: both return a Result. +const made = Game.Runtime.make({ + services: Game.Runtime.services(), + resources: { Time: Clock.start(step) } +}) if (made.ok) { const runtime = made.value const ticked = runtime.tick(Game.Schedule(AdvanceTime, Move)) @@ -76,47 +103,60 @@ if (made.ok) { ## Packages +All packages are released together with the same version. + | Package | Contents | |---|---| -| `@typeonce/ritmo` | The ECS runtime and the fixed-step loop | -| `@typeonce/ritmo-math` | Validated vectors, sizes, and bounding boxes; swept collision, spatial grid, flow field, deterministic random, and trig/exp/pow that return the same bits on every engine | -| `@typeonce/ritmo-browser` | Animation-frame tick source, keyboard, pointer, and touch input, input capture | -| `@typeonce/ritmo-render` | Entity to renderer node sync, for any renderer | -| `@typeonce/ritmo-pixi` | Pixi container adapter (`PixiNodeRegistry.make`) | -| `@typeonce/ritmo-devtools` | Headless debug sessions and trace reports ([README](./packages/devtools/README.md)) | -| `@typeonce/oxlint-plugin-ritmo` | Oxlint rules that keep simulation code deterministic and headless and runtime results checked ([README](./packages/oxlint-plugin/README.md)) | - -All packages are released together with the same version. +| `@typeonce/ritmo` | ECS runtime and fixed-step loop | +| `@typeonce/ritmo-math` | Vectors, collision, spatial grid, flow field, seeded random, same-bits trig | +| `@typeonce/ritmo-browser` | Animation-frame ticks, keyboard, pointer, and touch input | +| `@typeonce/ritmo-render` | Entity-to-node sync for any renderer | +| `@typeonce/ritmo-pixi` | Pixi adapter for the render node registry | +| `@typeonce/ritmo-devtools` | Headless debug sessions, reports, and `ritmo-devtools lint` | +| `@typeonce/oxlint-plugin-ritmo` | Lint rules for deterministic, headless simulation code | ## Set up your agent -Your agent learns Ritmo from what installs with it: the source and its JSDoc -in `node_modules/@typeonce/*/src`, each package's README and changelog, and -one skill with the principles for structuring a game. +Your agent learns Ritmo from what installs with it: the source and its JSDoc, +each package's README and changelog, and one skill with the principles for +structuring a game. + +1. Install the packages the game uses: + + ```sh + pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser + pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint + ``` -1. Install the packages the game uses, for example - `pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser` and - `pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint`. -2. Link the skill, so it updates with every Ritmo upgrade: +2. Link the skill into your agent's skills folder. A symlink keeps it in step + with every Ritmo upgrade. `.agents/skills` is shown; use the folder your + agent reads (for example `.claude/skills`): ```sh - mkdir -p .claude/skills - ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .claude/skills/ritmo # Codex: .agents/skills/ + mkdir -p .agents/skills + ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo \ + .agents/skills/ritmo ``` 3. Ask the agent to set up `.oxlintrc.json` from the [oxlint plugin README](https://www.npmjs.com/package/@typeonce/oxlint-plugin-ritmo) - and a lint entry for `ritmo-devtools lint` from the + and `ritmo-devtools lint` from the [devtools README](https://www.npmjs.com/package/@typeonce/ritmo-devtools), and to run both in the game's check. ## Guarantees -- Names are unique: a bound schema rejects two descriptors of one kind with the same name at compile time. -- Nothing structural happens implicitly. Queued commands and state transitions wait for an explicit `applyDeferred()` or `applyStateTransitions(...)` in the schedule. -- Every system sees each change, event, and transition published since its own previous run, exactly once. -- Failures are values. A system that fails rolls back its writes, and the tick returns the failure. -- Queries visit only entities carrying their rarest required component and cache their matches. CI fails a pull request whose runtime benchmarks regress over 25%. +- 🏷️ **Unique names.** A schema rejects two descriptors of one kind with the + same name at compile time. +- ⏸️ **No hidden structural changes.** Commands and state transitions wait + for an explicit `applyDeferred()` or `applyStateTransitions(...)`. +- 📬 **Every change seen once.** Each system sees what was published since + its own previous run, exactly once. +- ↩️ **Failures are values.** A failing system rolls back its writes, and + the tick returns the failure. +- ⚡ **Fast queries.** Queries visit only entities with their rarest + required component and cache their matches. CI fails on runtime + regressions over 25%. Ritmo was previously named `bevy-ts`: see the [rename guide](./MIGRATION.md). diff --git a/packages/core/README.md b/packages/core/README.md index 697eda5..17ff53d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,7 +18,7 @@ const Game = Schema.bind(Schema.fragment({ components: { Position } })) Modules are also importable by path, for example `@typeonce/ritmo/Result`. -The API reference is the JSDoc in `src/`: each module's header says what it is for and which neighbouring API to use instead. Upgrade notes are in `CHANGELOG.md`; breaking entries name the fix. The agent skill for building games ships in `skills/ritmo/SKILL.md`; link it into the game with `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .claude/skills/ritmo` (Codex: `.agents/skills/`). +The API reference is the JSDoc in `src/`: each module's header says what it is for and which neighbouring API to use instead. Upgrade notes are in `CHANGELOG.md`; breaking entries name the fix. The agent skill for building games ships in `skills/ritmo/SKILL.md`; link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. Related packages, released together with the same version: From 18a5534ab34f676fd8ff6385f1f8f2d151d20937 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 19 Oct 2026 11:31:22 +0900 Subject: [PATCH 3/6] Tone down the README, and link package docs on GitHub instead of npm Co-Authored-By: Claude Opus 5.5 --- .../agent-docs-and-simulation-coverage.md | 2 +- AGENTS.md | 2 +- README.md | 44 ++++++++----------- packages/browser/README.md | 2 +- packages/core/README.md | 12 ++--- packages/math/README.md | 4 +- packages/oxlint-plugin/README.md | 4 +- packages/pixi/README.md | 4 +- packages/render/README.md | 4 +- 9 files changed, 36 insertions(+), 42 deletions(-) diff --git a/.changeset/agent-docs-and-simulation-coverage.md b/.changeset/agent-docs-and-simulation-coverage.md index 8631ba0..cb1ed73 100644 --- a/.changeset/agent-docs-and-simulation-coverage.md +++ b/.changeset/agent-docs-and-simulation-coverage.md @@ -14,4 +14,4 @@ - **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. - **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 moved from the repository's `MIGRATION.md` into the `@typeonce/ritmo` changelog. - **JSDoc corrections.** Examples no longer provide randomness or time as services (use the `Random` resource and `Clock`), the `Command` module example declares its commands, inspector docs state that `added`/`changed` reads report records since the inspector's previous evaluation, and every example uses prefixed names (`"Game/Position"`). Guidance that lived only in the repository's guides (spawn order, `ReadonlyValue` helpers, re-entering a state, `thisTick` and plugin systems, service effects and rollback) is now in the JSDoc. -- Package READMEs link other packages by npm URL, so the links work on npm. +- Package READMEs link other packages' READMEs by GitHub URL, so the links work on npm. diff --git a/AGENTS.md b/AGENTS.md index 4675993..0580aaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ ## Docs - A game's agent sees only what npm ships: each package's `src` JSDoc, `README.md`, `CHANGELOG.md`, and the skill in [`packages/core/skills/ritmo`](./packages/core/skills/ritmo/SKILL.md). Guidance a game needs goes there, never in a repo-only file. - The root `README.md` is the only doc for humans. `AGENTS.md`, `ARCHITECTURE.md`, and `RELEASING.md` are for work on this repo. -- Package READMEs link other packages by their npm URL, never by a relative path or into `examples/`, which do not ship. +- Package READMEs link other packages' docs by their GitHub URL (`https://github.com/typeonce-dev/ritmo/blob/main/packages//README.md`), never by a relative path or into `examples/`, which do not ship. - A changeset for a breaking change carries its fix (a short diff or the exact replacement), so the shipped `CHANGELOG.md` is the upgrade guide. - The skill holds only principles that change where code and state go in a game; anything a JSDoc, README, or lint message already says stays out of it. diff --git a/README.md b/README.md index a5fbff2..6d34bb7 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,13 @@ **The agent-first, type-safe ECS runtime for games.** -🎮 You direct the mechanics and feel.
-🤖 Your coding agent writes the simulation.
-🛡️ Ritmo keeps the agent on track: +🎮 You direct the mechanics and feel. Your coding agent writes the simulation. -- ✅ **Wrong code does not compile.** Undeclared access and missing - requirements are type errors, and each error names the fix. -- 🔍 **Every run can be inspected.** Headless sessions show lints, - invariants, journals, and why each value changed. -- 🎨 **Your renderer, your loop.** Ritmo brings simulation, input, timing, - math, and renderer bridges. Drawing stays in your game. +Ritmo keeps the agent on track: wrong code does not compile and the error +names the fix, and headless runs show lints, invariants, and why each value +changed. + +Ritmo works with your renderer and game loop. Drawing stays in your game.

@@ -33,9 +30,8 @@ pnpm add @typeonce/ritmo ``` -- Requires TypeScript 5.9 or newer (tested on every version from 5.9 up). -- Leave `noErrorTruncation` off: errors name the fix near their start, and - an untruncated error can print a whole schema. +Requires TypeScript 5.9 or newer. Leave `noErrorTruncation` off: errors name +the fix near their start, and an untruncated one can print a whole schema. ```ts import { Clock, Descriptor, Schema } from "@typeonce/ritmo" @@ -139,24 +135,22 @@ structuring a game. ``` 3. Ask the agent to set up `.oxlintrc.json` from the - [oxlint plugin README](https://www.npmjs.com/package/@typeonce/oxlint-plugin-ritmo) + [oxlint plugin README](./packages/oxlint-plugin/README.md) and `ritmo-devtools lint` from the - [devtools README](https://www.npmjs.com/package/@typeonce/ritmo-devtools), + [devtools README](./packages/devtools/README.md), and to run both in the game's check. ## Guarantees -- 🏷️ **Unique names.** A schema rejects two descriptors of one kind with the - same name at compile time. -- ⏸️ **No hidden structural changes.** Commands and state transitions wait - for an explicit `applyDeferred()` or `applyStateTransitions(...)`. -- 📬 **Every change seen once.** Each system sees what was published since - its own previous run, exactly once. -- ↩️ **Failures are values.** A failing system rolls back its writes, and - the tick returns the failure. -- ⚡ **Fast queries.** Queries visit only entities with their rarest - required component and cache their matches. CI fails on runtime - regressions over 25%. +- A schema rejects two descriptors of one kind with the same name at compile + time. +- Commands and state transitions wait for an explicit `applyDeferred()` or + `applyStateTransitions(...)` in the schedule. +- Each system sees what was published since its own previous run, exactly + once. +- A failing system rolls back its writes, and the tick returns the failure. +- Queries visit only entities with their rarest required component and cache + their matches. CI fails on runtime regressions over 25%. Ritmo was previously named `bevy-ts`: see the [rename guide](./MIGRATION.md). diff --git a/packages/browser/README.md b/packages/browser/README.md index fea745a..fe9968c 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -8,6 +8,6 @@ pnpm add @typeonce/ritmo-browser @typeonce/ritmo The package owns browser plumbing only; schemas, resources, and schedules stay in your game. -Released together with [`@typeonce/ritmo`](https://www.npmjs.com/package/@typeonce/ritmo), always with the same version. +Released together with [`@typeonce/ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/core/README.md), always with the same version. Source: https://github.com/typeonce-dev/ritmo diff --git a/packages/core/README.md b/packages/core/README.md index 17ff53d..633e182 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -22,11 +22,11 @@ The API reference is the JSDoc in `src/`: each module's header says what it is f Related packages, released together with the same version: -- [`@typeonce/ritmo-math`](https://www.npmjs.com/package/@typeonce/ritmo-math): validated vectors, sizes, and bounding boxes -- [`@typeonce/ritmo-browser`](https://www.npmjs.com/package/@typeonce/ritmo-browser): animation-frame ticks for the fixed-step loop, keyboard, pointer, and touch input -- [`@typeonce/ritmo-render`](https://www.npmjs.com/package/@typeonce/ritmo-render): entity-to-node sync for any renderer -- [`@typeonce/ritmo-pixi`](https://www.npmjs.com/package/@typeonce/ritmo-pixi): Pixi container adapter for the render-node registry -- [`@typeonce/ritmo-devtools`](https://www.npmjs.com/package/@typeonce/ritmo-devtools): headless debug sessions and trace reports -- [`@typeonce/oxlint-plugin-ritmo`](https://www.npmjs.com/package/@typeonce/oxlint-plugin-ritmo): oxlint rules that keep simulation code deterministic and headless +- [`@typeonce/ritmo-math`](https://github.com/typeonce-dev/ritmo/blob/main/packages/math/README.md): validated vectors, sizes, and bounding boxes +- [`@typeonce/ritmo-browser`](https://github.com/typeonce-dev/ritmo/blob/main/packages/browser/README.md): animation-frame ticks for the fixed-step loop, keyboard, pointer, and touch input +- [`@typeonce/ritmo-render`](https://github.com/typeonce-dev/ritmo/blob/main/packages/render/README.md): entity-to-node sync for any renderer +- [`@typeonce/ritmo-pixi`](https://github.com/typeonce-dev/ritmo/blob/main/packages/pixi/README.md): Pixi container adapter for the render-node registry +- [`@typeonce/ritmo-devtools`](https://github.com/typeonce-dev/ritmo/blob/main/packages/devtools/README.md): headless debug sessions and trace reports +- [`@typeonce/oxlint-plugin-ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/oxlint-plugin/README.md): oxlint rules that keep simulation code deterministic and headless Source: https://github.com/typeonce-dev/ritmo diff --git a/packages/math/README.md b/packages/math/README.md index c032e7a..3985120 100644 --- a/packages/math/README.md +++ b/packages/math/README.md @@ -21,7 +21,7 @@ pnpm add @typeonce/ritmo-math @typeonce/ritmo ## Determinism across devices -JavaScript engines may approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run, so a session recorded on a phone stops replaying on a server. `Ops` has the same functions as pure-JavaScript ports of fdlibm, built only from operations every engine rounds exactly, so they return the same bits everywhere (within an ulp of the exact value). Use them in simulation code; `Math.abs`, `min`, `max`, `floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `+ - * /`, and `x ** 2` are already exact. `Vec2`, `Collide2`, and `FlowField` use `Ops` themselves, and the `ritmo/use-ops-for-math` rule of [`@typeonce/oxlint-plugin-ritmo`](https://www.npmjs.com/package/@typeonce/oxlint-plugin-ritmo) reports the `Math` calls left in simulation code. +JavaScript engines may approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run, so a session recorded on a phone stops replaying on a server. `Ops` has the same functions as pure-JavaScript ports of fdlibm, built only from operations every engine rounds exactly, so they return the same bits everywhere (within an ulp of the exact value). Use them in simulation code; `Math.abs`, `min`, `max`, `floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `+ - * /`, and `x ** 2` are already exact. `Vec2`, `Collide2`, and `FlowField` use `Ops` themselves, and the `ritmo/use-ops-for-math` rule of [`@typeonce/oxlint-plugin-ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/oxlint-plugin/README.md) reports the `Math` calls left in simulation code. ```ts import { Ops } from "@typeonce/ritmo-math" @@ -31,6 +31,6 @@ const velocity = { x: Ops.cos(heading) * speed, y: Ops.sin(heading) * speed } const damping = Ops.exp(-friction * dt) ``` -Released together with [`@typeonce/ritmo`](https://www.npmjs.com/package/@typeonce/ritmo), always with the same version. +Released together with [`@typeonce/ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/core/README.md), always with the same version. Source: https://github.com/typeonce-dev/ritmo diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index 3b5d095..1e5572c 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -14,7 +14,7 @@ Every report says what goes wrong and which Ritmo API to use instead. pnpm add -D @typeonce/oxlint-plugin-ritmo oxlint ``` -Released together with [`@typeonce/ritmo`](https://www.npmjs.com/package/@typeonce/ritmo), always with the same version. Requires oxlint 1.86 or newer. +Released together with [`@typeonce/ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/core/README.md), always with the same version. Requires oxlint 1.86 or newer. ## Setup @@ -100,7 +100,7 @@ Catches `Math.random()`, `crypto.getRandomValues()`, and `crypto.randomUUID()`. ### `ritmo/use-ops-for-math` -The spec lets engines approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run. [`Ops`](https://www.npmjs.com/package/@typeonce/ritmo-math#determinism-across-devices) from `@typeonce/ritmo-math` has each of them as a port that returns the same bits everywhere. +The spec lets engines approximate `Math.sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `exp`, `expm1`, `log`, `log2`, `log10`, `log1p`, `cbrt`, `pow`, `hypot`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, and `a ** b`, and they do: Chrome, Safari, macOS, and Linux disagree in the last bit, and a simulation grows that bit into a different run. [`Ops`](https://github.com/typeonce-dev/ritmo/blob/main/packages/math/README.md#determinism-across-devices) from `@typeonce/ritmo-math` has each of them as a port that returns the same bits everywhere. ```ts const turn = Math.atan2(Math.sin(b - a), Math.cos(b - a)) // reported diff --git a/packages/pixi/README.md b/packages/pixi/README.md index 5088d40..72b0eea 100644 --- a/packages/pixi/README.md +++ b/packages/pixi/README.md @@ -2,7 +2,7 @@ Pixi's implementation of the renderer-independent `NodeRegistry` contract. `PixiNodeRegistry.make` attaches nodes to one Pixi container and detaches and destroys them with their children on removal. -Import the generic `NodeRegistry` contract and `RenderSync` systems from [`@typeonce/ritmo-render`](https://www.npmjs.com/package/@typeonce/ritmo-render). The Pixi package exports only its adapter; systems depend on the generic registry service, and the host chooses its implementation. +Import the generic `NodeRegistry` contract and `RenderSync` systems from [`@typeonce/ritmo-render`](https://github.com/typeonce-dev/ritmo/blob/main/packages/render/README.md). The Pixi package exports only its adapter; systems depend on the generic registry service, and the host chooses its implementation. ```sh pnpm add @typeonce/ritmo-pixi @typeonce/ritmo-render @typeonce/ritmo @@ -22,6 +22,6 @@ const services = Game.Runtime.services(Game.Runtime.service(Sprites, sprites)) Bring your own `pixi.js` application; the package does not import it. -Released together with [`@typeonce/ritmo`](https://www.npmjs.com/package/@typeonce/ritmo), always with the same version. +Released together with [`@typeonce/ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/core/README.md), always with the same version. Source: https://github.com/typeonce-dev/ritmo diff --git a/packages/render/README.md b/packages/render/README.md index 0bb0e26..143d68e 100644 --- a/packages/render/README.md +++ b/packages/render/README.md @@ -33,8 +33,8 @@ const runtime = Game.Runtime.make({ }).value ``` -For Pixi, [`@typeonce/ritmo-pixi`](https://www.npmjs.com/package/@typeonce/ritmo-pixi) provides `PixiNodeRegistry.make(container)`, an implementation of the same registry contract. The host provides it with `Game.Runtime.service`; render systems keep depending on the generic service descriptor. +For Pixi, [`@typeonce/ritmo-pixi`](https://github.com/typeonce-dev/ritmo/blob/main/packages/pixi/README.md) provides `PixiNodeRegistry.make(container)`, an implementation of the same registry contract. The host provides it with `Game.Runtime.service`; render systems keep depending on the generic service descriptor. -Released together with [`@typeonce/ritmo`](https://www.npmjs.com/package/@typeonce/ritmo), always with the same version. +Released together with [`@typeonce/ritmo`](https://github.com/typeonce-dev/ritmo/blob/main/packages/core/README.md), always with the same version. Source: https://github.com/typeonce-dev/ritmo From 379f470f8f977fcea8cf41e993b806fbb419b605 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 19 Oct 2026 11:36:33 +0900 Subject: [PATCH 4/6] Add a For agents setup section and a starter prompt to the READMEs Co-Authored-By: Claude Opus 5.5 --- README.md | 39 +++++++++++++++++++++++++++------------ packages/core/README.md | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6d34bb7..e7bf33e 100644 --- a/README.md +++ b/README.md @@ -111,22 +111,32 @@ All packages are released together with the same version. | `@typeonce/ritmo-devtools` | Headless debug sessions, reports, and `ritmo-devtools lint` | | `@typeonce/oxlint-plugin-ritmo` | Lint rules for deterministic, headless simulation code | -## Set up your agent +## Get started with your agent -Your agent learns Ritmo from what installs with it: the source and its JSDoc, -each package's README and changelog, and one skill with the principles for -structuring a game. +Paste this into your coding agent in the game's folder: -1. Install the packages the game uses: +```text +Set up @typeonce/ritmo in this project, following the "For agents" section +of its README (https://github.com/typeonce-dev/ritmo#for-agents). +``` + +Then describe the game you want. + +## For agents + +Setting up Ritmo in a game: + +1. Install the runtime, the packages the game needs, and the checks: ```sh pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint ``` -2. Link the skill into your agent's skills folder. A symlink keeps it in step - with every Ritmo upgrade. `.agents/skills` is shown; use the folder your - agent reads (for example `.claude/skills`): + Add `@typeonce/ritmo-render` to mirror entities into a renderer, and + `@typeonce/ritmo-pixi` for Pixi. +2. Link the skill into the folder you read skills from (`.agents/skills`, + `.claude/skills`, ...), and follow it: ```sh mkdir -p .agents/skills @@ -134,11 +144,16 @@ structuring a game. .agents/skills/ritmo ``` -3. Ask the agent to set up `.oxlintrc.json` from the +3. Set up `.oxlintrc.json` from the [oxlint plugin README](./packages/oxlint-plugin/README.md) - and `ritmo-devtools lint` from the - [devtools README](./packages/devtools/README.md), - and to run both in the game's check. + and a `ritmo-devtools lint` entry from the + [devtools README](./packages/devtools/README.md). + Run typecheck, `oxlint`, `ritmo-devtools lint`, and the tests in the + game's check script. + +The API reference is the JSDoc in `node_modules/@typeonce/*/src`: read a +module's header before using it. Upgrade notes are in each package's +`CHANGELOG.md`. ## Guarantees diff --git a/packages/core/README.md b/packages/core/README.md index 633e182..b7ac534 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,7 +18,38 @@ const Game = Schema.bind(Schema.fragment({ components: { Position } })) Modules are also importable by path, for example `@typeonce/ritmo/Result`. -The API reference is the JSDoc in `src/`: each module's header says what it is for and which neighbouring API to use instead. Upgrade notes are in `CHANGELOG.md`; breaking entries name the fix. The agent skill for building games ships in `skills/ritmo/SKILL.md`; link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. +## For agents + +Setting up Ritmo in a game: + +1. Install the runtime, the packages the game needs, and the checks: + + ```sh + pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser + pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint + ``` + + Add `@typeonce/ritmo-render` to mirror entities into a renderer, and + `@typeonce/ritmo-pixi` for Pixi. +2. Link the skill into the folder you read skills from (`.agents/skills`, + `.claude/skills`, ...), and follow it: + + ```sh + mkdir -p .agents/skills + ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo \ + .agents/skills/ritmo + ``` + +3. Set up `.oxlintrc.json` from the + [oxlint plugin README](https://github.com/typeonce-dev/ritmo/blob/main/packages/oxlint-plugin/README.md) + and a `ritmo-devtools lint` entry from the + [devtools README](https://github.com/typeonce-dev/ritmo/blob/main/packages/devtools/README.md). + Run typecheck, `oxlint`, `ritmo-devtools lint`, and the tests in the + game's check script. + +The API reference is the JSDoc in `node_modules/@typeonce/*/src`: read a +module's header before using it. Upgrade notes are in each package's +`CHANGELOG.md`. Related packages, released together with the same version: From 3871db0b6fae43ce65169b7a80fe0491ed06ad86 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 19 Oct 2026 11:44:57 +0900 Subject: [PATCH 5/6] Make the skill the single setup entry, with base packages and add-ons Co-Authored-By: Claude Opus 5.5 --- .../agent-docs-and-simulation-coverage.md | 2 +- AGENTS.md | 2 +- README.md | 45 ++++--------------- packages/core/README.md | 33 +------------- packages/core/skills/ritmo/SKILL.md | 25 ++++++++++- 5 files changed, 34 insertions(+), 73 deletions(-) diff --git a/.changeset/agent-docs-and-simulation-coverage.md b/.changeset/agent-docs-and-simulation-coverage.md index cb1ed73..1154cf2 100644 --- a/.changeset/agent-docs-and-simulation-coverage.md +++ b/.changeset/agent-docs-and-simulation-coverage.md @@ -11,7 +11,7 @@ **Everything a game's agent needs now ships with the packages, and `ritmo-devtools lint` reports simulation modules the lint rules miss.** - **Breaking: `ritmo-devtools lint` fails on an uncovered simulation module.** Every module the entry loads that the `ritmo/keep-simulation-headless` override in `.oxlintrc.json` does not cover is reported as `uncovered-simulation-module`, and the command exits 1. Add the module (or its folder) to the simulation override's `"files"`, or stop importing values from it. Modules loaded only inside `lintTarget` are not checked. Requires Node 22.15 or newer (`engines.node` is now `>=22.15`). -- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. +- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: setup (the base packages and checks every game uses, and the add-ons), where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. - **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 moved from the repository's `MIGRATION.md` into the `@typeonce/ritmo` changelog. - **JSDoc corrections.** Examples no longer provide randomness or time as services (use the `Random` resource and `Clock`), the `Command` module example declares its commands, inspector docs state that `added`/`changed` reads report records since the inspector's previous evaluation, and every example uses prefixed names (`"Game/Position"`). Guidance that lived only in the repository's guides (spawn order, `ReadonlyValue` helpers, re-entering a state, `thisTick` and plugin systems, service effects and rollback) is now in the JSDoc. - Package READMEs link other packages' READMEs by GitHub URL, so the links work on npm. diff --git a/AGENTS.md b/AGENTS.md index 0580aaf..9e7d288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ - The root `README.md` is the only doc for humans. `AGENTS.md`, `ARCHITECTURE.md`, and `RELEASING.md` are for work on this repo. - Package READMEs link other packages' docs by their GitHub URL (`https://github.com/typeonce-dev/ritmo/blob/main/packages//README.md`), never by a relative path or into `examples/`, which do not ship. - A changeset for a breaking change carries its fix (a short diff or the exact replacement), so the shipped `CHANGELOG.md` is the upgrade guide. -- The skill holds only principles that change where code and state go in a game; anything a JSDoc, README, or lint message already says stays out of it. +- The skill is the single entry for a game's agent: setup (base packages, checks, add-ons), where the docs are, and principles that change where code and state go. Anything a JSDoc, README, or lint message already says stays out of it. The root README's starter prompt links it. ## Core Principles - Type safety is the first and absolute priority. diff --git a/README.md b/README.md index e7bf33e..f8caef6 100644 --- a/README.md +++ b/README.md @@ -113,47 +113,18 @@ All packages are released together with the same version. ## Get started with your agent -Paste this into your coding agent in the game's folder: +Paste this into your coding agent in the game's folder, then describe the game +you want: ```text -Set up @typeonce/ritmo in this project, following the "For agents" section -of its README (https://github.com/typeonce-dev/ritmo#for-agents). +Set up Ritmo in this project by following +https://raw.githubusercontent.com/typeonce-dev/ritmo/main/packages/core/skills/ritmo/SKILL.md ``` -Then describe the game you want. - -## For agents - -Setting up Ritmo in a game: - -1. Install the runtime, the packages the game needs, and the checks: - - ```sh - pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser - pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint - ``` - - Add `@typeonce/ritmo-render` to mirror entities into a renderer, and - `@typeonce/ritmo-pixi` for Pixi. -2. Link the skill into the folder you read skills from (`.agents/skills`, - `.claude/skills`, ...), and follow it: - - ```sh - mkdir -p .agents/skills - ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo \ - .agents/skills/ritmo - ``` - -3. Set up `.oxlintrc.json` from the - [oxlint plugin README](./packages/oxlint-plugin/README.md) - and a `ritmo-devtools lint` entry from the - [devtools README](./packages/devtools/README.md). - Run typecheck, `oxlint`, `ritmo-devtools lint`, and the tests in the - game's check script. - -The API reference is the JSDoc in `node_modules/@typeonce/*/src`: read a -module's header before using it. Upgrade notes are in each package's -`CHANGELOG.md`. +That file is the [Ritmo skill](./packages/core/skills/ritmo/SKILL.md). It +installs the packages and checks every Ritmo game uses, links itself into the +agent's skills folder so it updates with Ritmo, and tells the agent where the +docs are and how to structure the game. ## Guarantees diff --git a/packages/core/README.md b/packages/core/README.md index b7ac534..cb836d3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,38 +18,7 @@ const Game = Schema.bind(Schema.fragment({ components: { Position } })) Modules are also importable by path, for example `@typeonce/ritmo/Result`. -## For agents - -Setting up Ritmo in a game: - -1. Install the runtime, the packages the game needs, and the checks: - - ```sh - pnpm add @typeonce/ritmo @typeonce/ritmo-math @typeonce/ritmo-browser - pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint - ``` - - Add `@typeonce/ritmo-render` to mirror entities into a renderer, and - `@typeonce/ritmo-pixi` for Pixi. -2. Link the skill into the folder you read skills from (`.agents/skills`, - `.claude/skills`, ...), and follow it: - - ```sh - mkdir -p .agents/skills - ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo \ - .agents/skills/ritmo - ``` - -3. Set up `.oxlintrc.json` from the - [oxlint plugin README](https://github.com/typeonce-dev/ritmo/blob/main/packages/oxlint-plugin/README.md) - and a `ritmo-devtools lint` entry from the - [devtools README](https://github.com/typeonce-dev/ritmo/blob/main/packages/devtools/README.md). - Run typecheck, `oxlint`, `ritmo-devtools lint`, and the tests in the - game's check script. - -The API reference is the JSDoc in `node_modules/@typeonce/*/src`: read a -module's header before using it. Upgrade notes are in each package's -`CHANGELOG.md`. +Agents: setup, where the docs are, and how to structure a game are in the skill at `skills/ritmo/SKILL.md`. Follow its Setup section in a new project. Related packages, released together with the same version: diff --git a/packages/core/skills/ritmo/SKILL.md b/packages/core/skills/ritmo/SKILL.md index e440dc5..2d01890 100644 --- a/packages/core/skills/ritmo/SKILL.md +++ b/packages/core/skills/ritmo/SKILL.md @@ -1,16 +1,37 @@ --- name: ritmo -description: "Apply when building or changing a game on @typeonce/ritmo: gameplay, content, readouts, UI, saves, host integration, splitting modules, or upgrading Ritmo. Where the Ritmo docs are, and principles for where code and state belong so changes stay local and mistakes fail early." +description: "Apply when setting up @typeonce/ritmo in a project, or building or changing a game on it: gameplay, content, readouts, UI, saves, host integration, splitting modules, or upgrading Ritmo. Setup, where the Ritmo docs are, and principles for where code and state belong so changes stay local and mistakes fail early." --- # Ritmo games Follow the game's existing structure and conventions. These principles decide where something belongs; the game decides its folders, UI framework, renderer, timing, and design. +## Setup + +Do this once per project, and again for any step a project is missing. Use the project's package manager. + +1. Install the runtime and the checks every Ritmo game uses: + ```sh + pnpm add @typeonce/ritmo @typeonce/ritmo-math + pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint + ``` +2. Link this skill from the installed package into the folder your agent reads skills from (`.agents/skills`, `.claude/skills`, ...), so it updates with every Ritmo upgrade: + ```sh + mkdir -p .agents/skills + ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo + ``` +3. Set up `.oxlintrc.json` from the `@typeonce/oxlint-plugin-ritmo` README and a `ritmo-devtools lint` entry from the `@typeonce/ritmo-devtools` README. One check script runs the type check, `oxlint`, `ritmo-devtools lint`, and the tests. + +Add-ons, installed only when the game needs them: +- `@typeonce/ritmo-browser`: a browser host (animation-frame loop, keyboard, pointer, touch). +- `@typeonce/ritmo-render`: mirrors entities into any renderer's nodes. +- `@typeonce/ritmo-pixi`: the Pixi adapter for `@typeonce/ritmo-render`. + ## Where the docs are - The API reference is the JSDoc in `node_modules/@typeonce/*/src`. Read a module's header before using the module, and an export's JSDoc before calling it. Do not work from memory or the web: the API changes between versions. -- Each package's `README.md` covers its setup: `@typeonce/oxlint-plugin-ritmo` for `.oxlintrc.json`, `@typeonce/ritmo-devtools` for headless sessions and `ritmo-devtools lint`. +- Each package's `README.md` in `node_modules` covers its setup and workflow. - When upgrading, read each package's `CHANGELOG.md` from the installed version up. Breaking entries name the fix. - Compiler errors and lint messages name the fix. Apply it; never cast around it. From 292df8d5e1efdceb8bb3cbed9a7d5c767c0514b1 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 19 Oct 2026 11:48:13 +0900 Subject: [PATCH 6/6] Split one-time setup into SETUP.md; keep the skill for every change Co-Authored-By: Claude Opus 5.5 --- .../agent-docs-and-simulation-coverage.md | 2 +- AGENTS.md | 4 +-- README.md | 10 ++++---- packages/core/README.md | 2 +- packages/core/SETUP.md | 22 ++++++++++++++++ packages/core/package.json | 1 + packages/core/skills/ritmo/SKILL.md | 25 ++----------------- scripts/pack-check.ts | 6 +++-- 8 files changed, 38 insertions(+), 34 deletions(-) create mode 100644 packages/core/SETUP.md diff --git a/.changeset/agent-docs-and-simulation-coverage.md b/.changeset/agent-docs-and-simulation-coverage.md index 1154cf2..f9bc03a 100644 --- a/.changeset/agent-docs-and-simulation-coverage.md +++ b/.changeset/agent-docs-and-simulation-coverage.md @@ -11,7 +11,7 @@ **Everything a game's agent needs now ships with the packages, and `ritmo-devtools lint` reports simulation modules the lint rules miss.** - **Breaking: `ritmo-devtools lint` fails on an uncovered simulation module.** Every module the entry loads that the `ritmo/keep-simulation-headless` override in `.oxlintrc.json` does not cover is reported as `uncovered-simulation-module`, and the command exits 1. Add the module (or its folder) to the simulation override's `"files"`, or stop importing values from it. Modules loaded only inside `lintTarget` are not checked. Requires Node 22.15 or newer (`engines.node` is now `>=22.15`). -- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`: setup (the base packages and checks every game uses, and the add-ons), where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. +- **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`, for every change: where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. `SETUP.md` in `@typeonce/ritmo` is the one-time setup: the base packages and checks every game uses, the add-ons, and linking the skill. - **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 moved from the repository's `MIGRATION.md` into the `@typeonce/ritmo` changelog. - **JSDoc corrections.** Examples no longer provide randomness or time as services (use the `Random` resource and `Clock`), the `Command` module example declares its commands, inspector docs state that `added`/`changed` reads report records since the inspector's previous evaluation, and every example uses prefixed names (`"Game/Position"`). Guidance that lived only in the repository's guides (spawn order, `ReadonlyValue` helpers, re-entering a state, `thisTick` and plugin systems, service effects and rollback) is now in the JSDoc. - Package READMEs link other packages' READMEs by GitHub URL, so the links work on npm. diff --git a/AGENTS.md b/AGENTS.md index 9e7d288..64bcdb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,11 +29,11 @@ - A new pattern that games must follow or avoid, and that types and devtools cannot catch, becomes a rule in `packages/oxlint-plugin`, with tests from real code. Its message says what goes wrong and the exact API to use instead. ## Docs -- A game's agent sees only what npm ships: each package's `src` JSDoc, `README.md`, `CHANGELOG.md`, and the skill in [`packages/core/skills/ritmo`](./packages/core/skills/ritmo/SKILL.md). Guidance a game needs goes there, never in a repo-only file. +- A game's agent sees only what npm ships: each package's `src` JSDoc, `README.md`, `CHANGELOG.md`, `SETUP.md` in core, and the skill in [`packages/core/skills/ritmo`](./packages/core/skills/ritmo/SKILL.md). Guidance a game needs goes there, never in a repo-only file. - The root `README.md` is the only doc for humans. `AGENTS.md`, `ARCHITECTURE.md`, and `RELEASING.md` are for work on this repo. - Package READMEs link other packages' docs by their GitHub URL (`https://github.com/typeonce-dev/ritmo/blob/main/packages//README.md`), never by a relative path or into `examples/`, which do not ship. - A changeset for a breaking change carries its fix (a short diff or the exact replacement), so the shipped `CHANGELOG.md` is the upgrade guide. -- The skill is the single entry for a game's agent: setup (base packages, checks, add-ons), where the docs are, and principles that change where code and state go. Anything a JSDoc, README, or lint message already says stays out of it. The root README's starter prompt links it. +- [`packages/core/SETUP.md`](./packages/core/SETUP.md) is the one-time setup (base packages, checks, add-ons, linking the skill); the root README's starter prompt links it. The skill is for every change: where the docs are, and principles that change where code and state go. Anything a JSDoc, README, or lint message already says stays out of both. ## Core Principles - Type safety is the first and absolute priority. diff --git a/README.md b/README.md index f8caef6..3736151 100644 --- a/README.md +++ b/README.md @@ -118,13 +118,13 @@ you want: ```text Set up Ritmo in this project by following -https://raw.githubusercontent.com/typeonce-dev/ritmo/main/packages/core/skills/ritmo/SKILL.md +https://raw.githubusercontent.com/typeonce-dev/ritmo/main/packages/core/SETUP.md ``` -That file is the [Ritmo skill](./packages/core/skills/ritmo/SKILL.md). It -installs the packages and checks every Ritmo game uses, links itself into the -agent's skills folder so it updates with Ritmo, and tells the agent where the -docs are and how to structure the game. +[That file](./packages/core/SETUP.md) installs the packages and checks every +Ritmo game uses and links the [Ritmo skill](./packages/core/skills/ritmo/SKILL.md) +into the agent's skills folder. From then on, the agent follows the skill on +every change: where the docs are and how to structure the game. ## Guarantees diff --git a/packages/core/README.md b/packages/core/README.md index cb836d3..1f58917 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,7 +18,7 @@ const Game = Schema.bind(Schema.fragment({ components: { Position } })) Modules are also importable by path, for example `@typeonce/ritmo/Result`. -Agents: setup, where the docs are, and how to structure a game are in the skill at `skills/ritmo/SKILL.md`. Follow its Setup section in a new project. +Agents: set up a project with `SETUP.md`, then follow the skill at `skills/ritmo/SKILL.md` on every change. Related packages, released together with the same version: diff --git a/packages/core/SETUP.md b/packages/core/SETUP.md new file mode 100644 index 0000000..b1a2a5e --- /dev/null +++ b/packages/core/SETUP.md @@ -0,0 +1,22 @@ +# Set up Ritmo in a project + +Follow these steps once in a new project, and for any step an existing project is missing. Use the project's package manager. + +1. Install the runtime and the checks every Ritmo game uses: + ```sh + pnpm add @typeonce/ritmo @typeonce/ritmo-math + pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint + ``` +2. Link the Ritmo skill from the installed package into the folder your agent reads skills from (`.agents/skills`, `.claude/skills`, ...), so it updates with every Ritmo upgrade: + ```sh + mkdir -p .agents/skills + ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo + ``` +3. Set up `.oxlintrc.json` from the `@typeonce/oxlint-plugin-ritmo` README and a `ritmo-devtools lint` entry from the `@typeonce/ritmo-devtools` README. One check script runs the type check, `oxlint`, `ritmo-devtools lint`, and the tests. + +Add-ons, installed only when the game needs them: +- `@typeonce/ritmo-browser`: a browser host (animation-frame loop, keyboard, pointer, touch). +- `@typeonce/ritmo-render`: mirrors entities into any renderer's nodes. +- `@typeonce/ritmo-pixi`: the Pixi adapter for `@typeonce/ritmo-render`. + +Then use the skill for every change: it says where the docs are and how to structure the game. diff --git a/packages/core/package.json b/packages/core/package.json index 379d64a..65363de 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,7 @@ "README.md", "CHANGELOG.md", "skills", + "SETUP.md", "LICENSE" ], "exports": { diff --git a/packages/core/skills/ritmo/SKILL.md b/packages/core/skills/ritmo/SKILL.md index 2d01890..c6cc7b5 100644 --- a/packages/core/skills/ritmo/SKILL.md +++ b/packages/core/skills/ritmo/SKILL.md @@ -1,33 +1,12 @@ --- name: ritmo -description: "Apply when setting up @typeonce/ritmo in a project, or building or changing a game on it: gameplay, content, readouts, UI, saves, host integration, splitting modules, or upgrading Ritmo. Setup, where the Ritmo docs are, and principles for where code and state belong so changes stay local and mistakes fail early." +description: "Apply on every change to a game built on @typeonce/ritmo: gameplay, content, readouts, UI, saves, host integration, splitting modules, or upgrading Ritmo. Where the Ritmo docs are, and principles for where code and state belong so changes stay local and mistakes fail early." --- # Ritmo games Follow the game's existing structure and conventions. These principles decide where something belongs; the game decides its folders, UI framework, renderer, timing, and design. -## Setup - -Do this once per project, and again for any step a project is missing. Use the project's package manager. - -1. Install the runtime and the checks every Ritmo game uses: - ```sh - pnpm add @typeonce/ritmo @typeonce/ritmo-math - pnpm add -D @typeonce/ritmo-devtools @typeonce/oxlint-plugin-ritmo oxlint - ``` -2. Link this skill from the installed package into the folder your agent reads skills from (`.agents/skills`, `.claude/skills`, ...), so it updates with every Ritmo upgrade: - ```sh - mkdir -p .agents/skills - ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo - ``` -3. Set up `.oxlintrc.json` from the `@typeonce/oxlint-plugin-ritmo` README and a `ritmo-devtools lint` entry from the `@typeonce/ritmo-devtools` README. One check script runs the type check, `oxlint`, `ritmo-devtools lint`, and the tests. - -Add-ons, installed only when the game needs them: -- `@typeonce/ritmo-browser`: a browser host (animation-frame loop, keyboard, pointer, touch). -- `@typeonce/ritmo-render`: mirrors entities into any renderer's nodes. -- `@typeonce/ritmo-pixi`: the Pixi adapter for `@typeonce/ritmo-render`. - ## Where the docs are - The API reference is the JSDoc in `node_modules/@typeonce/*/src`. Read a module's header before using the module, and an export's JSDoc before calling it. Do not work from memory or the web: the API changes between versions. @@ -61,7 +40,7 @@ Add-ons, installed only when the game needs them: **Make the next mistake fail loudly.** - When a rule can be a type, a lint, or a headless test, encode it there instead of in a comment. - Verify a gameplay change with a headless run that asserts the observable outcome. Reproduce a bug headless before fixing it, then keep the reproduction as a test. -- Keep `oxlint` and `ritmo-devtools lint ` in the game's check. +- Keep the type check, `oxlint`, `ritmo-devtools lint `, and the tests in the game's check. If any is missing, add it as `node_modules/@typeonce/ritmo/SETUP.md` describes. - Test the simulation with `Determinism.check` from `@typeonce/ritmo-devtools`: it runs it twice and names the first value that differs, which catches hidden inputs and module state. ## Self-check diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index a7d7c71..d44f80a 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -52,8 +52,10 @@ for (const directory of packages) { if (listing.some((file) => file.includes("/test/") || file.includes("tsbuildinfo"))) { throw new Error(`${manifest.name} tarball contains test or build-info files`) } - if (directory === "core" && !listing.includes("package/skills/ritmo/SKILL.md")) { - throw new Error(`${manifest.name} tarball is missing the agent skill package/skills/ritmo/SKILL.md`) + if (directory === "core") { + for (const required of ["package/skills/ritmo/SKILL.md", "package/SETUP.md"]) { + if (!listing.includes(required)) throw new Error(`${manifest.name} tarball is missing ${required}`) + } } if (directory === "pixi" && listing.some((file) => /\/(NodeRegistry|RenderSync)\.(ts|js|d\.ts)$/.test(file))) { throw new Error("Pixi tarball contains removed compatibility modules")