Object-Oriented Prompt Engineering for JavaEst. 2026 · MIT License
PrOOPt

“A Java library that keeps LLMs on a need-to-know basis.”

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, including InflectionPoints (when things changed), TraitCorrelations (which feelings move together), and ForwardProjections (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.