Skip to content

ADR-0020 · accepted

A game is declared, one named block per part, and Layers host it ​

The kit grew as primitives and factories. A genre wrote a GameSimulation<Config, State, Input, Event> object, spread it into a GameDefinition, declared its own engine service, its own content service and a layer maker for each, and handed an EngineLoader of five type parameters to an app, which reached the engine through engineOf and turned events into Messages with a switch on type. Each piece was sound, but a reader couldn't see a game in it: the glossary named the parts (event, sample, engine, content) and the code spelled each as an interface to satisfy. A run also changed in two ways, modify for one player and step input in lockstep, so survivors and grimbonk each wrote every player decision twice.

The kit was meant to be a framework that makes a game easy to write, read and learn from. The reference is @alchemy.run/domain, which models a business as named declarations (class DepositMoney extends Domain.Command(...)) that Layers host, with the rules as plain handlers inside.

Decision ​

Each part of a game is one named declaration. A declaration is a class made by a builder, class X extends Game.Y("X", { ... }) {}. It carries its definition and its types, and runs nothing by itself.

  • Game.Event (arcadekit/game): a rare fact a run reports, or its sample. A Schema class, so it goes into a Foldkit Message as it is.
  • Game.Command: a decision a player makes outside what their device holds. Also a Schema class.
  • Game.Rejection: a reason the rules refuse a command. A Schema class and an error, so an Effect fails with it.
  • Game.Simulation: the DOM-free rules. input (a Schema), events, commands, create, step, one apply handler per command, and sample.
  • Game.Engine (arcadekit/game/browser): a simulation plus the browser half, view and input, and what they are made from (uses).
  • Content and Asset (arcadekit/content): what a game is played with, and what it draws from, each loaded once.

The rules stay plain. create, step and apply are plain functions: create makes the state, and step and apply change it in place and return nothing (an apply handler may return a rejection instead). In the reference, the handler that produces the state (evolve) is a plain function with no Effect form; the one that judges a command (decide) is plain by default and may be an Effect.fn to read what middleware provides. A step is the first kind. ADR-0017 holds: a step runs no Effect and reads no clock or random source of its own. The kit holds a game to it twice. A handler's return type is void or a value nobody can make, so a step that returns anything (a promise, an Effect, the next state) and an apply handler that returns anything but one of its command's rejections are type errors, where a plain => void would let each compile. And the loop refuses the same at run time, for what a cast or plain JavaScript gets past the types, since it would otherwise drop it in silence. What changes is the cost rule. A step may allocate what it reports (an event is new LevelSolved({ moves }), and events are rare), and still keeps what it holds per entity in typed arrays, so a step over many entities allocates nothing for them. Determinism is correctness, since replays and lockstep depend on it; the old "a step must not allocate" was never true of events, which were object literals.

Layers host a declaration. Game.Loop steps a simulation anywhere and owns what every genre repeated: the event buffer, the command queue and the sample. MyEngine.layer provides the engine service, MyContent.layer(source) the content, MyAsset.layer(load) an asset. A class made by Game.Engine, Content or Asset is its service's key, and the type of its values is the service's identity, so Layer.Layer<MyEngine> reads as it does for a hand-written service.

A run changes only at a step. engine.send(command) queues a command, and the loop applies it at the start of the next step, before step, in the order sent. modify has no counterpart on the new engine. A replay or a lockstep peer that gets the same input and commands computes the same run, and a game writes each decision once.

A command can be refused. A Game.Rejection is a reason the rules turn a command down. A command lists the ones it can end with (rejects), and its apply handler returns one in place of changing the state, deciding before it changes anything. This is the reference's decide returning a rejection, as a plain returned value. engine.send is an Effect that completes when the rules have answered: it succeeds if the handler took the command and fails with the rejection if it refused. It also fails with CommandDropped if the rules gave no answer, because there was no run or the run ended first, so a send ends when its run next steps or ends. The sender goes on after the frame of the command's step, never inside it: Game.Loop tells a sender once it is out of the call that ended the command, and the engine resumes it after a yield, so the line after a send may start or stop a run. Interrupting a send takes the command back. A refusal is not an event: nothing happened in the run, and only the sender hears of it.

The engine exists before its assets. uses names Effects the engine runs on each attach: a service, an asset's load, something the view and the input share. A failed load is attach's failure, which engineEntry's onError already reports. EngineLoader, LoadedEngine and engineOf have no counterpart: an app's loading screen runs the same MyAsset.load to show a failure and retry.

A scenario checks the rules. arcadekit/game/scenario runs given/step/send/reported/refused scenarios through Game.Loop, so a rule is stated as an example, and a passing scenario accounts for every event the run reported, every command the rules refused and every command sent.

It lands one game at a time. The declarations are built on GameLoop, which is unchanged, so the step order is one. Push-puzzle and its demo are on them. Survivors and the Co-op client still use GameSimulation, makeGameEngine, makeEngineLoader and CoopRules, which stay until they have moved; none of them is kept as a compatibility layer after that.

Considered Options ​

  • Effect inside the step: each rule an Effect, with the run (its Rng, its dt, its event sink) as a service. The most uniform, and what "an Effect framework" means read literally. Written out and measured for this decision. It computes the same run, and at one Effect per rule its time is affordable (about 1 to 12 microseconds a step against 0.15 plain). Rejected because of what the step's type then says and doesn't say: Effect<void, never, Run> returns nothing and has no failure a caller should handle, so only Run carries information, and that is the context argument a plain step already takes; and it can't say the step finishes in the call. A rule can sleep, await or read the wall clock and keep that type, and lint doesn't see it. Run synchronously, a step that waits throws, and its fiber finishes the step later unless the host interrupts it; run on a fiber, Effect's scheduler cuts a long step after 2,048 operations with no wait written anywhere. The kit would have to keep those details of Effect's runtime pinned in the one place where a mistake is a desync. It also allocates 4 to 18 KB a step. The reference draws the same line for the handler that changes state.
  • An event-sourced state: decide returns events and evolve folds them into an immutable state, as an aggregate does. Rejected for games: a step moves thousands of entities, and the log a game replays is already its input and commands (state = fold(step, create(config, seed), inputs)). Events here are facts for the outside, not the state's source.
  • Keep the factories and add sugar: helpers that build the same GameSimulation and service boilerplate. Rejected: the vocabulary would still live in the glossary and not in the code.
  • The presentation as one Effect that returns { view, input }. Written first and dropped: TypeScript can't type the view's state through that nested generic call, so every render needed an annotation. A view that itself returns an Effect would type, as the reference's handlers do. uses with flat, plain view and input handlers was chosen over it: the handlers stay plain functions, the engine owns their acquire and release, and the view and the input can share one value made per attach.
  • The reference's names for it, Story and then. Story is already Foldkit's test of an app's update, which the apps' test files import, so a test that needed both would have to rename one. The kit's is a Scenario. A module can't export then: its namespace is then a thenable, and under vitest a test file that imports it never starts. The reference keeps the word by putting its steps on one exported object, imported as { Story }, with a lint exception for unicorn/no-thenable. That works here too, at the price of that import in place of import * as Scenario, the exception, and promise/valid-params off in tests, since "nothing was reported" is the zero-argument call that rule flags. Scenario.reported was chosen: it needs none of that, and it is in the reference's own grammar for a check (replied, rejected, emitted). Little else of the vocabulary carries over anyway: there is no when, since a command is only queued until the next step.

Consequences ​

  • Three entries, one new arrow. arcadekit/game, arcadekit/game/browser and arcadekit/game/scenario join the package (ADR-0018), which now has twenty-one. game may import core and runtime. When the last game has moved, runtime's GameLoop moves into game and the runtime entry goes.
  • ADR-0001 is set aside for this change. It is the kit's author picking a shape, which that decision allows, and three games show the needs it answers: both genres repeat the service and event plumbing, and survivors and grimbonk both write each decision twice.
  • ADR-0010 keeps its split (a DOM-free simulation, a browser presentation, one loop) under new names: Game.Simulation, Game.Engine's view and input, Game.Loop.
  • ADR-0015 keeps its ownership rule: the app owns the sheet, says where it comes from and frees it at its end. The engine loader that enforced it is replaced by the Asset the genre declares.
  • Lint. eslint/max-classes-per-file is off, since a declaration is a class and a file of events has several. vitest/expect-expect counts scenario as an assertion. The determinism rules cover all of arcadekit/game but its engine, and the root entry of every genre package. The declaration/… rules hold a game's declarations and the kit's builders to their shape (docs/code-style.md).
  • The kit's own source can't write class X extends Game.Event(...) {} in an exported position, because it is checked with isolatedDeclarations: it names the base first, as Audio does. Genre packages and apps aren't, and write it plainly.
  • Still to move, in this order: survivors' single-player path (events, ChooseUpgrade as a command, SurvivorsEngine); then co-op, where a command rides a player's step input and replaces Answer, settled and the optional keys of HeroInput, every peer's rules refuse it alike and only the sender's peer has a send to fail, and where the lockstep run becomes another host of the same simulation; then save and audio as declarations; then grimbonk on the published package.

Released under the MIT License.