The state lives outside the model: how a desk pet knows you haven't talked to it in three days
An LLM with memory gives you a friendly chatbot. A state machine with an injected clock gives you a creature. The line "I know" is only funny if it's true.
NOVA is a desk creature with eyes, memory and a personality. The whole project hangs from a four-line script:
You: “I built a ChatGPT pet.” The creature: “You haven’t talked to me in 3 days.” You: “What?” The creature: “I know.”
The obvious thing would be to plug in a language model with memory and let it improvise. What you get from that is a friendly chatbot, not a creature. And the three-days line, if the model generates it, is a plausible sentence. If a timer generates it, it’s true. The difference shows in the one moment that counts.
The decision
The state lives outside the model. It’s four values: affection, energy, mood and last interaction. They decay over time according to rules of their own, with tests. The inputs (three buttons today, a sentiment classification of what you say tomorrow) inject values. And there are proactive triggers: the device speaks without being spoken to because a timer ran out.
The model receives that state in the prompt and writes the line. Sometimes not even that: some lines are written by hand. “An LLM with memory” versus “a Tamagotchi that can talk”. The second is what I want to build.
The domain, without a framework
The personality module is pure Java: zero dependencies, not a single Spring
annotation. Spring wraps it from the outside and exposes it over WebSocket;
the logic doesn’t know a framework exists, and the tests run in milliseconds.
Four classes: Creature (the state machine), Tuning (every personality
constant in one place), Snapshot (what the device receives) and the enums.
Five decisions that hold:
- Continuous exponential decay, not stepwise. Three days in one jump gives exactly the same as 72 one-hour jumps, and there’s a test that checks it. Without that property, a device that disconnects for an afternoon comes back with the wrong state, and that bug is hellish to find on a board.
- Mood reverts towards a baseline set by affection. The long-term bond sets the resting state; events perturb it. It’s one line of code and it’s what makes the creature consistent with itself.
- Diminishing returns on the buttons. A saturation variable that decays. Without it, hammering the heart maxes out affection and the mechanic feels fake within ten seconds.
- Energy isn’t persisted: it’s derived from local time. A pure function of the clock, lowest at 4:00, highest at 16:00. There’s nothing to store.
- The
Snapshotdoesn’t return geometry. Not an eyelid angle, not a curve: it returns what it feels and at what pace. The shape lives in the firmware, which allows redesigning the whole face without touching the personality.
Time is injected
All of the logic depends on the passage of time, and depending on the wall
clock would make it impossible to test. Creature takes a java.time.Clock
in its constructor. In production it’s the system clock; in the tests I
control it, and I simulate three days, a week or six months of silence in
milliseconds, with no board on the desk.
That is, incidentally, the reason for choosing Java over Python or Node for
the backend: Clock is literally the injectable time source the design asks
for. The class exists for this. In other languages I’d have ended up with
monkeypatching or a hand-rolled abstraction.
When the event history was added, a bug turned up that’s worth telling. The replay clock was born in 1970 by default until someone set it to a date. I fixed it by taking away the constructor’s ability to be born without knowing what time it is: the initial instant became mandatory. Objects that can be born in an invalid state end up being born in an invalid state.

Ethics, as constants with tests
The “it gets sad if you ignore it” mechanic is the product’s hook and its
risk at the same time: charming or coercive. I decided that wouldn’t be an
intention but three numbers in Tuning, each with its own test:
| Constant | Value | What it guarantees |
|---|---|---|
affectionRecoveryFactor | 2.5 | Affection rises 2.5 times faster than it falls. Coming back always pays more than leaving. |
affectionFloor | 15 | It never reaches zero. There is no dead creature that makes you feel guilty to look at. |
neglectCooldown | 24 h, no escalation | It mentions it at most once a day and never raises its tone. |
It isn’t “says it once and never again”, which would be worse: if you left for two months, it would mention it on day three and go silent forever. It’s at most once a day, without escalating. If someone ever loosens those three numbers, let it be knowing what they’re loosening.
A timeline
start affection= 55.0 mood= +0.0 NEUTRAL CALM
❤️ affection= 75.0 mood= +30.0 HAPPY CALM
+2 h affection= 74.5 mood= +19.9 NEUTRAL CALM
😡 affection= 72.5 mood= -21.3 HURT CALM
+8 h affection= 70.7 mood= +6.4 NEUTRAL TIRED <- the anger wore off on its own
+3 days affection= 56.4 mood= +2.5 NEUTRAL TIRED -> fires NEGLECTED
+2 weeks affection= 28.9 mood= -8.4 BORED TIRED
❤️ returns affection= 48.9 mood= +21.6 HAPPY TIRED <- asymmetric recoveryStill to be tuned by living with it, not by theorising: should two weeks of silence leave it at 28? Should a single heart lift it from 28 to 48? But the question is now about two specific numbers, not a feeling, and that’s the advantage of having taken the state out of the model.
What I’m taking away
When something has to be true, don’t ask a text generator for it. The model is good at putting things into words; it’s bad at knowing. The state, the time and the rules live in code with tests, and the model receives the result and phrases it. It’s a less showy architecture than “an AI with memory”, and it’s the only one in which “I know” means anything.