Every component in Atomic begins as a simulation — an atom holding state and a few pure functions that update it. You call those functions directly and watch state ripple through signals. That’s enough to build an entire app.
But at some point, you may want your components to speak in messages rather than function calls — to act.
Early on, you might write:
$.swap($game, g.concede)A direct call. Simple, but opaque — the intent and the transition are fused.
You can shift to a message-based form:
$.swap($game, _.act(_, {type: "concede"}))It looks the same on the surface, but this time you’re sending a command into the component. The command describes what should happen; the actor decides what does happen. The result is still a new object replacing the old one in your atom.
An actor is a component that can receive and process messages.
It’s defined by the IActor protocol:
| Method | Description |
|---|---|
act(self, command) |
Receives a command — an intention to change something. It may be accepted or rejected. Accepted commands produce one or more events, which are immediately applied. |
actuate(self, event) |
Receives an event — a fact about what happened — and folds it into state, yielding the next actor. |
actuate(self, event, reducer) |
Overload: the third argument is a reducing function that applies the event to the current state and returns the new state. Implementers switch on the event’s type and delegate to this version. |
events(self) |
Returns all known events. |
undone(self, event) |
Optionally reports whether an event can be reversed. |
actuate is pure. It must never generate new messages.
Event messages are meant to be safely replayable; any indeterminate work or decision-making belongs inside act.
Because of the overload, existing domain functions remain useful.
That earlier concede function still runs — it just becomes the reducer that applies a conceded event to the current state.
All messages share a simple shape:
{ type: "concede", details: { ... } }We use active voice for commands and past tense for events. Usually, it’s as easy as adding a “d”:
| Kind | Example | Meaning |
|---|---|---|
| Command | {type: "concede"} |
A request to end the game. |
| Event | {type: "conceded"} |
A record that the game has ended. |
A command is an intention. It expresses what should happen, but not what did happen. Commands can be rejected if they violate a rule or the current state.
An event is a fact. It tells what actually happened after a command was accepted.
Events are immutable and replayable. Fold them through actuate to rebuild state from scratch.
Inside an actor, every change follows the same simple path:
command → act → events → actuate → next actor
actdecides what events to generate and actuate.actuateapplies each event (purely) to the state.- The resulting actor — updated state, events logged — replaces the old one in your atom.
That’s the entire simulation loop.
Once you’re comfortable with commands and events, a third kind of message enters the picture: effects.
Here’s the twist — from the perspective of a component, effects don’t actually exist. A component never concerns itself with what the outside world should do. It has no awareness beyond its own boundaries.
All authority flows downward. A component doesn’t reach upward to speculate or orchestrate. It owns its work and reports what happened — that’s what events are for.
Events describe facts. Commands express intent.
Effects, then, are not new kinds of messages. They’re derived. When a higher-level authority observes an event, it may choose to respond by sending new commands to its subordinates. This translation — from observed event to triggered command — is what we call an effect.
In other words:
Event → (orchestration logic) → Command(s)
The orchestration layer, whether a parent component or mediator, performs that translation. It observes child events, decides what they mean, and dispatches new commands downward in response.
Effects are therefore not things a component emits — they are commands triggered elsewhere in response to events. Events may continue bubbling up, giving higher authorities the context they need to direct their own domains.
Actors model intent with commands; confirmed changes are events.
act decides which events to produce; actuate applies them, purely.
It's still just a reduction:
command → act → events → actuate → next actor
At creation (init), you can inject services alongside state — this is where inversion shows up. The actor defines what should happen, not how.
You swap the service, not the logic.
In the earlier stages, init returned a plain JavaScript object. That was fine when the atom only needed a little data to begin simulating, but init is not a rule. It’s an idea: every atom needs an initial seed. What you plant there can be bare data or a richer type with its own contracted interface.
Thus init naturally gives way to more deliberate factories — constructors that assemble services, wrap data, and return something that knows how to act.
Both dice share a roll interface and fix sides at construction. One is seeded and deterministic; the other uses real randomness.
// dice.js
export function pseudoRandomDie(seed, sides = 6) {
let s = seed >>> 0
function next() { s = (1664525 * s + 1013904223) >>> 0; return s }
return { roll() { return (next() % sides) + 1 } }
}
export function randomDie(sides = 6) {
return { roll() { return (Math.random() * sides | 0) + 1 } }
}The actor doesn’t know which die it has — only that it can roll. On "roll", it generates a "rolled" event with details, then actuates it.
// game/core.js
//`init` promoted to a factory function
export function pig(services = {}) {
return new Pig({ ..., services });
}
function act(self, command) {
if (command.type === 'roll') {
const rolled = self.services.die.roll()
const event = { type: 'rolled', details: { rolled } }
return actuate(self, event);
}
// ...
return self;
}
$.doto(Pig,
// .. implements protocols
_.implement(_.IActor, {act, actuate}));At startup, you choose whether Pig simulates chance or actually employs it.
// main.js
const die = randomDie(); // or pseudoRandomDie(42)
const $pig = $.atom(pig({ die })); //init
$.swap($pig, _.act(_, { type: 'roll' }));That’s dependency inversion in miniature — the actor stays pure and declarative; you decide whether the world is chaotic or predetermined.
This model is a hybrid, mostly pure, but there's no Either monad for accommodating errors. Instead, I took a simpler route: I throw errors from within act. It’s less pure, but it’s pragmatic.
I use these throws to uphold rules and reject invalid requests. They act as guardrails, signaling that the command shouldn’t proceed. From there, the shell catches them — treating each as though it were just another effect.
If you wanted to push through to total purity, it wouldn’t take much. You could surface those failures in the component state instead of exceptions — and allow them to bubble up as error events which, in turn, produce effects.
As always in software, there’s freedom in the tradeoff. You can handle rejection as data or as control flow. For now, I lean toward throwing — a small concession to impurity in service of keeping things simple.
You began with a simple atom you swapped functions against. Now you’re swapping messages.
There are only two: commands (intentions) and events (facts). That’s enough to build a fully simulated, replayable world.
You can always generate whatever effects, commands for subordinates, you deem useful.
Everything still happens inside the atom. The component remains a simulation — you just taught it how to act.