- Agent-first: coding agents write all code that uses Ritmo; a human directs the game and does not read the code.
- The library is the agent's guardrail, in three layers: a wrong call does not compile and the error names the fix; headless devtools sessions expose lints, invariants, and why a value changed; every found bug becomes a test.
- One way to do each thing. Two APIs for one job is a safety bug: an agent will use both.
- Names and JSDoc are API: each export says what it is for, when to use it, and which neighbouring API to use instead.
- Early library: break, remove, and rename freely. No deprecated aliases or re-exports.
packages/*: the published packages (see eachpackage.jsondescription). Each hassrc,test,dtslint, and core, math, and render havebench.integration/*: headless game simulations with tests, using the packages by name.bench/compare: Ritmo against bitECS, Koota, and Miniplex on the community ECS benchmark cases (pnpm bench:compare). Internal, not a gate; run it before and after a storage or hot-path change.test/checks every claim the website's comparison page makes about the other libraries.website/: the guide for humans (Astro), built from the workspace packages and deployed after each release..changeset/README.md: releases and adding a package.
- Every change:
pnpm run check. - Changes to public APIs, or to
website/:pnpm --dir website build(Node 22.23.4 or newer). - JSDoc
@exampleblocks compile inpnpm check(pnpm check-examples): modules are in scope by name, the game fromscripts/examples/prelude.ts, anything else above a// ---cut---line. The header ofscripts/check-examples.tsis the convention. - Changes to what a package publishes (exports,
package.json, build config):pnpm pack:check. - Every change to a published package: a changeset (
pnpm changeset). A breaking entry carries its fix. - Changes to runtime internals or public types:
pnpm bench:check; record an intended shift withpnpm bench:update. For runtime work, compare with your own machine:pnpm bench --json .bench/before.json, thenpnpm bench --compare .bench/before.json(--filter <text>for one case). Under about 20% is noise; do not report it. Add a bench case for a new per-frame hot path.
- Type safety comes first. If a runtime failure can be made impossible by types, it must be; when a guarantee depends on runtime state, the type says it can fail.
- No user-facing casts, no explicit generic arguments for normal use, no splitting schedules or features only to satisfy the compiler.
- Expected failure is a typed
Result, not an exception. No broad overloads or ambiguous semantics. - Small composable pieces, each type-safe on its own.
- Validate exact structure at the constructor boundary, derive a normalized carried type once, and reuse it. Optimize internal types only when the public API and its guarantees stay the same.
- Ritmo exports no codec builders. Values are Standard Schemas the game brings. A Ritmo-owned type comes with its guard (
Random.is,Clock.isDeadline), andRandomalso with one codec constant for itself.packages/core/dtslint/NoCodecBuilders.tst.tsfails on an export that returns a schema. - ritmo-math holds the 2D helpers most games would otherwise rewrite, plain data in and out, built on
Ops; it never ports a third-party algorithm, and determinism alone is never a reason to add one. - A new system access category gets a runtime provision only if the runtime must validate it (one nominal requirement token); otherwise it stays local to the system spec.
- Runtime semantics stay explicit: handles are storage-safe, not proof of liveness; lookups that depend on runtime state are fallible; commands and state transitions apply only at
applyDeferred()/applyStateTransitions(...); reads (changes, events, transitions, relation failures) are per-reader streams that show each system what was published since its own previous run, once.
- TSTyche in
packages/*/dtslint/*.tst.ts. Every public API gets an inferred-type test (expect(...).type.toBe<...>()) and a failing test per constraint. - A failing test names its error:
// @ts-expect-error <fragment>orexpect(fn).type.not.toBeCallableWith(...)next to a passing call. Pick fragments that name the cause, not printed unions.
- Packages never draw. They hand games the data to draw (like
Touch'sview()) and own glue every game would repeat: bridges to renderers and platforms (nodes, positions, input, timing). - A package owns an integration boundary, holds infrastructure (not gameplay), and is varied through typed inputs and callbacks. Feature bundles stay in games.
- A plugin is
(Game, options) => System | Schedule: no plugin objects, registries, or composition steps. - Generic contracts have generic names (
NodeRegistry); adapters name their implementation (PixiNodeRegistry) and export only it. The host provides the adapter as a service. - Core and generic modules call no platform API (
performance,window, timers, file system, network): a platform detail is a contract with injected implementations (HostClock/BrowserHostClock). Package configs get only the ES2022 lib; a host package that needs DOM types lists"DOM"itself. - Package and simulation code read time from
Clock, randomness fromRandom, and transcendental math fromOps;pnpm lintenforces this with the repo's own plugin. Disable a rule only with// oxlint-disable-next-line ritmo/<rule> -- <reason>. - A pattern games must follow that types and devtools cannot catch becomes a rule in
packages/oxlint-plugin, tested with real code, whose message says what goes wrong and the API to use instead.
- A game's agent sees only what npm ships:
srcJSDoc, eachREADME.mdandCHANGELOG.md,packages/core/SETUP.md(one-time setup), and the skill inpackages/core/skills/ritmo(every change, with playbooks). Guidance a game needs goes there, never in a repo-only file, and says nothing a JSDoc, README, or lint message already says. - Humans read the root
README.mdand the website (website/, see its README): a guide with running demos and snippets compiled by Twoslash, never an API reference. A change that breaks a website snippet or demo fixes it in the same change; the website build in CI fails until it does. - Package READMEs link other packages' docs by GitHub URL (
https://github.com/typeonce-dev/ritmo/blob/main/packages/<name>/README.md), never by relative path or intointegration/.
- Reproduce headless first, as the skill's
playbooks/bug-fix.mddescribes;integration/top-down/debug.tsis a script to copy (node --import tsx <script>).
Follow Effect for how APIs are shaped, keeping Ritmo's stricter rule (user-facing safety and explicitness first):
- Dependencies are in the type. What a system needs (services, resources, machines) is declared and carried as a requirement; the runtime provides it, and a missing provision does not compile. No globals, singletons, or ambient context.
- Failures are in the type. Expected errors are typed values in the return type (
Result), named and narrowable; exceptions are only for defects. - Contracts and implementations are separate. A generic service (
NodeRegistry,HostClock) is implemented per platform (PixiNodeRegistry,BrowserHostClock) and provided by the host, like Effect'sFileSystem/NodeFileSystem. - Validate at the boundary, then trust the type. Constructors decode untrusted input once; the carried type is derived once and reused.
- Modules are namespaces of small composable functions (
Clock.hz,Random.draw), each independently type-safe, with inference doing the work and no explicit generics in normal use.