DEV Community

Cover image for Cloudagotchi Part 3 : It gets hungry while you sleep: A serverless pet brain
Olivier Leplus for AWS

Posted on

Cloudagotchi Part 3 : It gets hungry while you sleep: A serverless pet brain

At the end of part 2, our pet had a face, reflexes, and a serious philosophical problem: no memory. You could pet it a hundred times, reboot the board, and it woke up with the same hardcoded stats, feeling nothing, remembering nothing. Every day was its first day.

Today the pet gets what every Tamagotchi fundamentally is: a state machine with feelings. Its hunger, energy and mood become rows in DynamoDB. A Lambda decides how a snack or a shake changes them. And, the part I find genuinely delightful, EventBridge Scheduler makes time pass, so the pet gets hungry overnight while the device is completely powered off. When you flash the firmware Monday morning, the pet wakes up, asks the cloud "what did I miss?", and gets visibly sad about the answer.

In this article, I will walk you through the whole brain: the data model (it's one table with one row per pet... gloriously boring), the decay math that makes offline time count, IoT rules that invoke Lambda without a single line of polling code, and the loop back down to the device.

⚠️ Reality check (sorry 😅): there's a Scan in this article. A full-table Scan, in production-adjacent code, published on the internet under my name. It's the right call for a fleet of three pets and I'll explain the honest scaling path when we get there, but if your DynamoDB instincts just twitched, I respect you, and I ask you to hold that thought until the 💡 box.

Code for this article: git checkout article-3.


The architecture, now with a brain

Architecture diagram showing IoT rules flowing to Lambda, Lambda reading/writing DynamoDB, and EventBridge Scheduler triggering the decay Lambda

Two Lambdas, one table, one schedule, two IoT rules. The entire brain deploys with cdk deploy and costs approximately nothing (the decay Lambda runs 48 times a day; the free tier laughs at this).

The data model: one row of feelings

Every pet is one item in DynamoDB:

{
  "deviceId": "cloudagotchi-01",
  "hunger": 62,
  "energy": 71,
  "mood": 45,
  "updatedAt": 1752192000000
}
Enter fullscreen mode Exit fullscreen mode

That's it. No GSIs, no sort keys, no single-table-design galaxy brain. The partition key is the thing name from part 1, and updatedAt is the timestamp that makes everything else in this article work.

The CDK for it is appropriately tiny:

const table = new dynamodb.Table(this, 'PetState', {
  tableName: 'cloudagotchi-pets',
  partitionKey: { name: 'deviceId', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  removalPolicy: cdk.RemovalPolicy.DESTROY,  // it's a pet, not a bank
});
Enter fullscreen mode Exit fullscreen mode

The rules of life: pure functions first

Before any AWS SDK calls, the pet's biology lives in pet-logic.mjs, pure functions, no I/O, trivially testable:

// Stats decay per hour of real time. Tuned so a full day of neglect
// makes the pet visibly sad — without starving it to zero over a weekend.
const DECAY_PER_HOUR = { hunger: 2, energy: 1.5, mood: 1 };

// What each interaction does.
const INTERACTION_EFFECTS = {
  feed: { hunger: +30, energy: +5,  mood: +5 },
  pet:  { hunger: 0,   energy: 0,   mood: +10 },
  play: { hunger: -10, energy: -15, mood: +20 },  // fun is exhausting
};
Enter fullscreen mode Exit fullscreen mode

And here's the single most important function in the whole project. Not a scheduled job, a calculation:

/** The cloud remembers, so the pet ages even while powered off. */
export function applyDecay(pet, now) {
  const hours = (now - pet.updatedAt) / 3_600_000;
  return {
    ...pet,
    hunger: clamp(pet.hunger - DECAY_PER_HOUR.hunger * hours),
    energy: clamp(pet.energy - DECAY_PER_HOUR.energy * hours),
    mood:   clamp(pet.mood   - DECAY_PER_HOUR.mood   * hours),
    updatedAt: now,
  };
}
Enter fullscreen mode Exit fullscreen mode

Here's the part that trips people up about "time passing" in serverless systems: you don't need a process running to make time pass. Time passes for free, you only need to account for it whenever you next look. applyDecay computes elapsed hours since updatedAt and applies them, whether that's 30 minutes (scheduler tick) or a weekend (you went camping, the pet noticed).

Finally, three stats collapse into one face for the device to render:

export function moodOf(pet, hourOfDay) {
  if (hourOfDay >= 22 || hourOfDay < 7) return 'sleeping';
  const worst = Math.min(pet.hunger, pet.energy, pet.mood);
  if (worst > 60) return 'happy';
  if (worst > 30) return 'neutral';
  return 'sad';
}
Enter fullscreen mode Exit fullscreen mode

Math.min : the pet's outlook is determined by its worst stat, not its average. As a design decision this is straight from the Tamagotchi school: you can't cuddle your way out of starvation. 😝

From MQTT to Lambda: IoT rules

How does a {"type":"feed"} on an MQTT topic become a Lambda invocation? IoT rules, SQL statements (yes, SQL) that run against the message stream:

new iot.CfnTopicRule(this, 'InteractionRule', {
  ruleName: 'cloudagotchi_interactions',
  topicRulePayload: {
    sql: `SELECT *, topic(2) AS deviceId, topic(3) AS kind
          FROM 'cloudagotchi/+/interaction'`,
    actions: [{ lambda: { functionArn: onInteraction.functionArn } }],
  },
});
Enter fullscreen mode Exit fullscreen mode

Two tricks earn their keep here:

  • topic(2) extracts the second topic segment (the device's thing name) and injects it into the event as deviceId. The device never has to say who it is in the payload, which means a buggy (or malicious) device can't claim to be a different pet. Identity comes from the topic, and part 1's policy already guarantees a device can only publish on its own topics. The security work we did in article 1 quietly pays rent here.
  • The + wildcard means one rule serves every pet, current and future. Provision a hundred devices; deploy nothing.

There's a twin rule on the hello topic, pointing at the same Lambda.

The interaction Lambda: load, decay, apply, save, reply

The handler reads like the pet's inner monologue:

export const handler = async (event) => {
  const { deviceId, kind, type } = event;   // injected by the IoT rule SQL
  const now = Date.now();

  // 1. Load the pet, or meet it for the first time.
  const { Item } = await ddb.send(new GetCommand({ TableName: TABLE, Key: { deviceId } }));
  let pet = Item ?? newbornPet(deviceId, now);

  // 2. Time passed since we last looked. It always does.
  pet = applyDecay(pet, now);

  // 3. A "hello" just wants the current state; an interaction changes it.
  if (kind === 'interaction') {
    pet = applyInteraction(pet, type);
  }

  // 4. Persist, then tell the device what it now feels.
  await ddb.send(new PutCommand({ TableName: TABLE, Item: pet }));
  await publishState(pet);
};
Enter fullscreen mode Exit fullscreen mode

Step 2 is doing quiet heavy lifting: because every code path decays first, the pet's state is always correct-as-of-now, no matter which event arrived or how long it's been. The hello flow (device boots, publishes hello, Lambda answers with fresh state) is the same code path as feeding, minus one if. "What did I miss?" is just "load + decay + reply."

The reply goes back down over MQTT via the IoT data plane:

await iot.send(new PublishCommand({
  topic: `cloudagotchi/${pet.deviceId}/state`,
  qos: 1,
  payload: JSON.stringify({ hunger, energy, mood, face: moodOf(pet, hour) }),
}));
Enter fullscreen mode Exit fullscreen mode

EventBridge Scheduler: the heartbeat of the universe

One question remains: if nobody interacts and the device never says hello, who makes the pet sad? This does:

new scheduler.CfnSchedule(this, 'DecaySchedule', {
  scheduleExpression: 'rate(30 minutes)',
  flexibleTimeWindow: { mode: 'OFF' },
  target: { arn: decay.functionArn, roleArn: schedulerRole.roleArn },
});
Enter fullscreen mode Exit fullscreen mode

Every 30 minutes, the decay Lambda sweeps all pets, ages them, and pushes fresh state to any device that's listening:

export const handler = async () => {
  const { Items = [] } = await ddb.send(new ScanCommand({ TableName: TABLE }));
  const now = Date.now();

  for (const pet of Items) {
    const aged = applyDecay(pet, now);
    await ddb.send(new PutCommand({ TableName: TABLE, Item: aged }));
    await publishState(aged);  // offline device? IoT drops it; the
                               // "hello" on next boot catches it up.
  }
};
Enter fullscreen mode Exit fullscreen mode

💡 About that Scan (deep breath). For a hobby fleet, a 48×/day scan of a table with single-digit rows is free and honest. At real scale you'd flip the model: don't sweep, don't even store decayed values: store only updatedAt plus the stats at last interaction, and compute decay on read (we already do!). Then the scheduled sweep exists solely to push updates to online devices, and you'd drive it from the list of connected things instead of the whole table. The lazy-evaluation trick in applyDecay is the scalable part; the Scan is the shortcut bolted onto it. Know which is which and you can ship both.

Closing the loop on the device

The firmware's on_cloud_message callback from part 1 finally has a job, parse the state and hand it to the face from part 2:

cJSON *json = cJSON_ParseWithLength(payload, len);
pet_ui_set_state(hunger->valueint, energy->valueint,
                 mood->valueint, mood_from_string(face->valuestring));
Enter fullscreen mode Exit fullscreen mode

Fifteen lines of cJSON (in the repo), and the loop is closed: DynamoDB is now the pet's soul, the screen is just its face.

The payoff

Deploy and flash:

cd backend && npx cdk deploy CloudagotchiBrainStack
cd ../firmware && idf.py flash monitor
Enter fullscreen mode Exit fullscreen mode

Screenshot of the DynamoDB item showing the pet's row with hunger, energy, and mood values

Now run the experiment that justifies this whole architecture. Tap the pet, the bars nudge up (device → rule → Lambda → DynamoDB → MQTT → face, ~200 ms round trip). Then unplug the board. Go live your life for a day. Plug it back in and watch the boot sequence: hello goes up, and the state that comes back reflects every offline hour. The pet's smile flattens. Its hunger bar sits noticeably lower. It missed you, and it has the DynamoDB row to prove it.

Side-by-side photos: The pet happy with full bars vs. the pet sad after a day unplugged with low bars

I'm not saying I felt guilty. I'm saying I fed it immediately.


Why this matters

Strip away the pet and what we built is the canonical pattern for cloud-authoritative device state: device sends events, cloud computes truth, truth flows back down, and lazy time-accounting on read replaces any always-running process. That's a smart thermostat's schedule, a subscription's billing period, a game's energy system, an IoT sensor's calibration drift. The Tamagotchi version just has better animations.

Our pet now has a body, reflexes, memory, and consequences. One thing left in the series, and it's the one I've been saving: giving it a job. In part 4, the pet fetches the AWS news every morning, has Amazon Bedrock rewrite them in its own voice, and reads them to me out loud through Amazon Polly. My Cloudagotchi becomes my news anchor. 📰


Try it yourself

Top comments (0)