Chapter Five
States — Developer Tutorials
Six more lessons. By the end, you will have a society, and the society will have a paper trail.
Lesson 1 — From One Persona to a Population
One guard with feelings is a curiosity. Ten thousand citizens with
feelings is an instrument. The generator builder creates populations
whose trait distributions you control by construction — pin a trait
here, draw another from a normal distribution there, and shape the
whole thing with weighted Segments:
List<Citizen> city = citizens.generate()
.limit(50_000)
.seed("Adult residents of a mid-size rust-belt metro, 2026")
.distribute(Segment.of("suburban homeowners, fixed-rate mortgages", 0.34)
.pinTrait("housing_status", "OWNER_FIXED")
.normalDistribution("rate_sensitivity", 35, 12)
.normalDistribution("institutional_trust", 48, 15)
.build())
.distribute(Segment.of("urban renters, gig income", 0.22)
.pinTrait("employment_mode", "GIG")
.normalDistribution("rate_sensitivity", 72, 9)
.build())
.build();
Generation is batched, parallel, and rate-limited
(.batchSize(), .parallelism(),
.rateLimit()), and buildWithResult() hands
back a BuildResult with a success rate and any failed
batches, because honesty about partial failure is a feature.
Lesson 2 — Events with Physics: broadcast, Blast Radius, Rumour
In most systems, an event is a message. In PrOOPt States, an event is a disturbance in a medium. When something happens, you declare how far it travels, whom it reaches, and how the truth holds up along the way:
guards.phaseManager().broadcast(
crimeWitnessed,
EventPipeline.create()
.blastRadius(BlastRadius.WITNESSED
.union(BlastRadius.FACTION))
.attenuation(AttenuationModel.RUMOUR)
.timing(EventTiming.IMMEDIATE)
.minSeverity(EventSeverity.WARNING));
Read it aloud and it is almost a sentence: everyone who saw
it, plus their faction, hears about it immediately — and as the story
passes from guard to guard, it degrades the way rumours do.
BlastRadius scopes propagation (WITNESSED,
FACTION, RELATIONSHIP,
PROXIMITY); AttenuationModel.RUMOUR applies
the classic distortion psychology — details get levelled, the dramatic
bits get sharpened, and the rest gets bent to fit what the listener
already believed. You can also target a broadcast surgically with a
plain predicate: broadcast(event, "district = 'HARBOR'").
Lesson 3 — Time as a Force: Evolution Schedules
Real entities change even when nothing happens to them. Bread goes
stale, grudges soften, attention drifts. The @Persona
annotation gives time a job description:
@Persona(
value = "An adult resident of a mid-size metro with a realistic "
+ "financial life, media diet, social ties, and bounded attention.",
evolutionSchedule = "@daily",
evolutionDescription = "Attention to last week's news decays; lived "
+ "financial pressure (rent, groceries, rate resets) reasserts "
+ "itself over headlines."
)
public class Citizen { ... }
On the cron schedule you set, an evolution batch job visits each
persona and applies the drift you described — no external stimulus
required. Changes land in the history table marked
UpdateSource.EVOLUTION, so a citizen who calmed down on
her own is never confused with a citizen who was talked down by a
neighbour.
Lesson 4 — The Internal Loop, or: Entities That Sleep On It
This is the strangest and most useful feature in the module. Enable the internal loop and your persona periodically reflects on what it has been through — consolidating memory, revising beliefs — during quiet hours, at a depth you choose:
@Persona(
value = "The payments service: latency-sensitive, PCI-scoped, "
+ "dependent on auth, ledger-db, and the card-network gateway.",
internalLoop = true,
loopSchedule = "0 0 3 * * *", // 3 a.m., as is traditional
loopDepth = LoopDepth.SHALLOW,
loopQuietPeriodHours = 6,
loopDescription = "Review the day's digests against my biography. "
+ "Is anything slowly going wrong that no single day would reveal?"
)
public class PaymentsServiceTwin { ... }
Three controls keep the introspection sensible.
LoopDepth sets how far the entity may wander —
SHALLOW tidies recent experience, MODERATE
reconciles it with existing beliefs, and DEEP is
belief-revision territory, best scheduled weekly.
loopQuietPeriodHours is the quiet-period gate: no
philosophising mid-incident — the loop only runs after things have
been calm for the period you set. And every loop-driven change is
tagged UpdateSource.INTERNAL_LOOP, so you always know
which thoughts were the entity's own.
Lesson 5 — Reactive Governance: @OnPersonaEvent and the Veto
So far the LLM has been doing all the becoming. Now for the other half of the bargain: deterministic Java gets to watch, react, and — when necessary — refuse. Subscribers fire on threshold crossings and population-level trends:
@PersonaSubscriber
public class CityAlertService {
@OnPersonaEvent(persona = GuardNpc.class,
type = PersonaEventType.THRESHOLD_CROSSED,
trait = "suspicion", threshold = 80,
direction = Direction.ABOVE)
public void lockdown(TraitChangeEvent<GuardNpc> e) {
world.triggerGateLockdown(e);
}
@OnPersonaEvent(persona = GuardNpc.class,
type = PersonaEventType.POPULATION_TREND,
trend = Trend.FALLING)
public void watchCollapse(PopulationTrendEvent e) {
story.unlockMutinyArc();
}
}
PersonaMetrics aggregate trait values across the
population and can emit their own threshold events — an early-warning
system over the society's mood. And if the LLM ever proposes a state
transition your rules find implausible (morale jumping forty points
because someone said good morning), a lifecycle hook can throw
PersonaUpdateVetoException and the change simply does not
happen. The LLM proposes. Java disposes. It is the same authority
split as PrOOPt core, wearing a different hat.
Lesson 6 — Reading the Story Back: Reports
Every change to every trait of every persona is a source-attributed
row in prooopt_persona_history. The reporting layer turns
that ledger into narrative instruments:
EvolutionReport— the longitudinal view of an entity or cohort, includingInflectionPoints (when things changed),TraitCorrelations (which feelings move together), andForwardProjections (where this is heading).reportWhere(...)— cohort-level reports over a window, ideal for treatment-versus-control comparisons.customReport(prompt)— hand the LLM the population's history and ask for prose: a chronicle of the garrison's morale, a draft postmortem, an analyst's memo.
EvolutionReport report = guards.report("guard-042",
EvolutionWindow.lastDays(30));
report.inflectionPoints()
.forEach(p -> log.info("{} — {}", p.when(), p.why()));
This is the payoff of all that disciplined bookkeeping: you can ask when did this guard stop trusting the player, and what exactly caused it? — and get an answer with timestamps, sources, and reasons rather than a shrug.
Graduation, Again
You can now mint populations, send events that travel like gossip, let time and reflection do their quiet work, keep deterministic Java's hand on the veto, and read the whole story back as data. To see these parts assembled into things nobody has built before — living game worlds, policy wind tunnels, machine diaries — proceed to the Use Cases.
