Emergent: A Synthetic Ethology Platform for ESP32-Class Hardware

← Back to project overview

Table of Contents

This document describes what the firmware does. History, prior art, speculative modality ideas, and philosophy live in research.md. Section numbers are kept stable, so there is a gap (2).


Status

Layer Fact
Implemented 20 Hz loop: sense → physiology → drives → memory → action → actuate → learn. Learned sensor→actuator reflexes trained by drive reduction. Named-channel body. SoftAP dashboard. Life-state JSON. Station firmware as a separate state machine.
Tested Host-native tests for bit packing, age saturation, rate-limit clamp, LEDC cap, and organism behavior (energy dynamics, contingency bias, reflex learning rule) against a fake body. Not real hardware.
Simulated The unmodified engine in a modelled room with a station (src/sim): learned reflexes lift mean lifespan from 1.26 h to 1.86 h (hand-wired bound 2.46 h). Learns to reach and stay on the dock; not yet to leave when full, and nothing accumulates across lives.
Hardware tested Nothing. No board has run this.
Designed / planned Body declared in a JSON file with per-input ranges, stepper and mic-envelope channel kinds (example build); approach-and-dock on hardware; extra boards.
Hypothesis That drives + decaying memory + learned reflexes + noise will look alive on real hardware, including charging as an attractor. Unverified.

1. Introduction

The problem is simple to state and hard to do well: make a small, cheap, battery-powered machine look alive.

Not intelligent in the large-model sense, not conversational, and not necessarily useful for a fixed task. Instead, we want a machine that appears to have:

Humans are extremely sensitive to motion, timing, and contingency. A few sensors, motors, delays, and feedback loops are enough for us to describe behavior using words like curious, cautious, bored, or interested. Heider and Simmel’s classic experiment showed that people attribute agency and intent even to moving geometric shapes.

In this project, such words are observer-level summaries of dynamics, not claims about subjective experience. The concrete engineering goal is:

Construct a machine whose behavior is best understood in terms of internal regulation, learning, and interaction with its environment, rather than in terms of fixed scripts.

The central constraints are deliberate:

Instead, we rely on:

The remainder of this document presents the system architecture, algorithms, and experimental considerations for this synthetic ethology platform. Theory, prior art, and philosophical framing live separately in research.md.


3. Project Overview and Design Goals

We aim for an architecture that:

  1. Runs on an ESP32-class microcontroller.

  2. Treats signals symmetrically.

  3. Implements a minimal “organism” architecture.

  4. Connects behavior to energy without scripting it.

  5. Supports serializable “life states”.

  6. Is suitable as a research/education platform.


4. System Architecture

4.1 System Overview Diagram

gates + reward

reward

exploration taken

Environment
(room, station, light, walls)

Sensors

Normalization
(0..1 per channel)

Actuator Gateway
(clamp, rate limit)

Safety Floor
(thermal, battery)

Physiology
(adaptation, habituation)

Drives
(squared: reward = drop)

Contingency
Memory

Spatial
Memory

Learned Reflexes
(per-drive maps)

Action
Generator

Dashboard /
Life-state JSON

Everything above the gateway is the behavior engine, header-only and identical in the firmware and the simulator. The only reward anywhere is the drop in drive pressure; the only privileged wire is battery → h_energy.

4.2 Layers and Responsibilities


5. The Body: Sensors and Actuators

5.1 Actuators

Actuators are generic effectors. The system does not know what they “mean”.

Example actuator set:

Name Type Range Physical Example
motor_left/right continuous -1 … +1 Differential drive motors
servo_pan/tilt continuous -1 … +1 Head or sensor orientation
vibe_motor continuous 0 … 1 Vibration motor
grind_motor continuous 0 … 1 Noisy mechanical actuator (“voice”)
led_r/g/b continuous 0 … 1 RGB lighting / signaling
fan continuous 0 … 1 Airflow actuator
pump/atomizer continuous 0 … 1 Fluid or scent emission
heater continuous 0 … 1 Local thermal field
electromagnet continuous 0 … 1 Gripping, field perturbation

The reference boards (src/esp32/include/boards/) wire only motor_left/right, led_status, led_r/g/b, and head_pan. Implemented actuator kinds: kPwmUnipolar, kPwmBidirectional, kDigitalOut, kServo.

Each actuator has:

5.2 Sensors

Sensors provide normalized numeric streams. No label is privileged.

Example sensors:

Name Type Example Hardware
mic_energy, bands, peaks continuous MEMS microphone
distance_* continuous ToF, ultrasonic, IR
touch_* cont/binary Bump switches, capacitive strips
light_*, uv continuous Photodiodes, light sensors
imu_accel/gyro/mag continuous 6-axis or 9-axis IMU
temperature, humidity continuous Environmental sensors
gas, air_quality continuous Metal-oxide gas sensor
rf_rssi_* continuous Wi-Fi/BLE RSSI or beacons
battery_voltage, current continuous ADC monitoring of power rails
internal_temp continuous MCU or board temperature

The reference boards wire only battery_voltage, touch_bump, and rf_rssi_station. Implemented sensor kinds: kAdcNormalized, kDigitalIn, kWifiRssi.

Battery signals enter at this layer as raw numbers, like any other sensor.

5.3 Body Schema as an Emergent Construct

We do not predefine kinematics or a body model. The intent (untested on hardware) is that contingency memory picks up regularities such as:

This echoes how infants learn their own bodies through co-occurrence of actuation, proprioception, and exteroception.


6. Internal Physiology and Drives

6.1 Variables and Dynamics

The internal state contains a small vector of slow variables, for example:

Salience. Every sensor channel is predicted to keep changing at the rate it just did, and only a miss beyond a few times that channel's own learned jitter counts as an event. So ADC noise is never an event, a steady turn habituates away, and a bump, a new light or a sound stands out.

A social variable (history of correlated signals from other robots/humans) was considered but isn't implemented: it would need a "presence of another agent" sensor no current board has, and an unimplemented drive that never moves is worse than no drive at all. See research notes for the idea.

Each variable:

Drives are scalar pressures derived from deviations from preferred bands. The bands are part of the organism's constitution: energy [0.65, 1] (hunger from about half charge on a LiPo read across its full voltage span), fatigue [0, 0.6], safety [0.7, 1], arousal [0, 0.7], boredom [0, 0.4].

drive[name] = deviation(h_name, target_range[name])
homeostatic distance D = sum over drives except curiosity of drive^2
reward each tick      = D(t-1) - D(t)

That reward, drive reduction, is the only one anything learns from (Keramati & Gutkin 2014, with their exponent n = 2: relieving a large deficit outweighs small fluctuations). Curiosity is left out of it on purpose: it rises with surprise, so counting it would make every novel outcome a setback.

6.2 Drive Modulation Algorithm

Drives shape the scale and direction of stochastic exploration.

def compute_drives(phys, targets):
    drives = {}
    for name, value in phys.items():
        lo, hi = targets[name]
        if value < lo:
            drives[name] = (lo - value) / (hi - lo + 1e-6)
        elif value > hi:
            drives[name] = (value - hi) / (hi - lo + 1e-6)
        else:
            drives[name] = 0.0
    return drives

def calculate_fuzz_scale(drives):
    curiosity = drives.get("h_curiosity", 0.0)
    boredom   = drives.get("h_boredom", 0.0)
    energy    = drives.get("h_energy", 0.0)   # high = low resource
    safety    = drives.get("h_safety", 0.0)   # high = unsafe

    fuzz = (
        0.3 * curiosity +
        0.4 * boredom   +
        0.3 * energy    -
        0.3 * safety
    )
    return clamp(fuzz, 0.05, 0.8)

Intuitively:

Exploiting what has been learned when hungry comes from the hunger-gated reflexes (§12), not from standing still.


7. Contingency Memory

7.1 Data Structure

Contingency memory holds many small records of “when I did X, Y tended to follow”:

Entry:
    action_delta_code    # compressed Δaction at t-1
    sensor_delta_code    # compressed Δsensor at t
    strength             # reliability / confidence
    age                  # ticks since last reinforcement
    ctx_hash             # 1 bit per physiology variable, set if >= 0.5
    mean_drive_delta     # EMA of how much total drive fell after this pattern

Quantization and hashing compress high-dimensional deltas into small codes so memory remains bounded.

7.2 Update Algorithm

LEARN = 0.1          # kTuning.contingency.learn_rate
DECAY = 0.005        # kTuning.contingency.decay_rate
PRUNE = 0.02         # kTuning.contingency.prune_threshold
MAX_ENTRIES = 256    # ContingencyMemory::kCapacity

def update_contingency(mem, d_action, d_sensor, ctx_hash):
    # decay existing entries; reclaim ones that decayed to nothing
    for e in mem.values():
        e.strength *= (1.0 - DECAY)
        e.age = min(e.age + 1, AGE_MAX)
        if e.strength < PRUNE:
            del mem[e.key]

    key = hash_quantized(d_action, d_sensor, ctx_hash)

    if key in mem:
        e = mem[key]
        e.strength += LEARN * (1.0 - e.strength)
        e.age = 0
    else:
        mem[key] = Entry(
            action_delta_code = compress(d_action),
            sensor_delta_code = compress(d_sensor),
            strength          = LEARN,
            age               = 0,
            ctx_hash          = ctx_hash,
        )

    if len(mem) > MAX_ENTRIES:
        evict_weakest(mem)

When generating new actions, the system queries for entries whose sensor_delta_code is within a small Hamming distance of what it just observed and whose mean_drive_delta is positive (the pattern helped), and biases toward the best one's action_delta_code. Patterns that only made drives worse are never recalled.

7.3 No Privileged Semantics

The firmware never encodes:

Those are interpretations we may assign later. For the agent, there are only:


8. Spatial Memory

No supported board has a positioning sensor: no encoders, no IMU, no GPS. So a "place" is a sensor signature, and spatial memory cannot steer. It only changes how much exploration noise the action generator injects.

8.1 Places from Sensor Signatures

Each sensor reading in [0,1] is binned into its quartile (2 bits per channel). The packed bits are the place's signature: two spots that read the same are the same place, and one spot whose readings drift is several places. With the reference boards' three sensors there are at most 64 distinct places.

A table of 32 cells (open addressing, evict least-visited) stores, per place:

Sensor Signature
(quartile per channel)

Place Cell

Visit Count / Age

Drive Improvement
per variable

8.2 Explore/Exploit Modulation, Not Steering

def spatial_nudge(phys, spatial, body):
    best = max(spatial.cells, key=lambda c: sum(phys.drive[v] * c.drive_improvement[v] for v in VARS))
    if best is None or best.score <= 0:
        return 0.0
    m = clamp(best.score * SPATIAL_GAIN, 0.0, SPATIAL_NUDGE_MAX)   # 0.5, 0.3
    return -m if best.signature == signature(body) else +m

# in the action generator, per actuator:
noise_term = noise() * fuzz * span * NOISE_GAIN * (1 + spatial_nudge)

If the current place is the best one remembered for what's pressing now, noise narrows (stay). If a better place is remembered, noise widens (move on). It does not know which way that place is.


9. Core Loop

9.1 Behavior Flow Diagram

Sense

UpdatePhysiology

UpdateContingency

UpdateSpatial

CreditReflexes

ProposeAction

Actuate

SafetyEnforce

reward = drop in drive pressure
since the previous tick

One pass every 50 ms (20 Hz), inside Arduino loop(). CreditReflexes and ProposeAction both happen inside ActionGenerator::tick(): the reflexes are first credited with how drives changed since the last action, then propose the next one.

9.2 Core Loop Pseudocode

Mirrors loop() in src/esp32/src/main.cpp:

void behavior_tick(float dt_s, uint32_t now_ms) {
    // 1. Physiology + drives. Reads the energy sensor and actuator costs;
    //    curiosity consumes contingency memory's prediction error from the
    //    previous tick.
    phys.update(body, dt_s, contingency.last_surprise());

    // 2. Learn. Contingency: (Δactuators since last tick, Δsensors since
    //    last tick, physiology context bits) -> reinforce/insert, tagged
    //    with how total drive changed. The previous tick's action is judged
    //    by what the sensors did over the following 50 ms; there is no
    //    separate propagation delay.
    contingency.update(body, phys, dt_s);
    spatial.update(body, phys, dt_s);   // place signature + drive change here

    // 3. Act. For each actuator not under manual override:
    //    next = clamp((reflex + explore) * (1 - rest) + contingency_bias)
    //    reflex:  learned sensorimotor map (§12), learns from this tick's reward first
    //    explore: Ornstein-Uhlenbeck noise (tau 1 s) * fuzz * span * (1 + spatial_nudge)
    //    rest:    shrink toward 0 (zero cost) with fatigue and hunger
    action_gen.tick(body, phys, contingency, spatial, now_ms);

    // 4. Hard limits, last: per-channel thermal budget, critical-battery cut.
    safety.enforce(body, dt_s);
}

10. Battery as “Just Another Input”, Coupled to Energy

Inputs: battery_voltage, current.
No explicit “charge” routine.

Battery signals enter as normal sensor channels. The only “privilege” they have is in the physiology update:

Whether that adds up to something an observer would call self-charging is the hypothesis in §11.7, not a result. Other modality ideas (phototaxis, scent trails, acoustic signaling) are in research.md.


11. Charging Station

The charging station is part of the ecology, not a coded “home base”. It should:

From the robot’s perspective, the station is just a cluster of regularities:

The hypothesis (§11.7) is that those regularities are enough for the robot to end up at the station when energy is low. The station firmware in src/station implements the RF beacon, LED, and click cues; nothing has been observed on hardware.

11.1 Station Architecture Overview

The station is:

Station

Room / Environment

Mechanical Frame
& Guides

Power Interface
(contacts or inductive)

RF Beacon
(Wi-Fi/BLE/etc.)

LED Cluster
(distinct pattern)

Optional Audio
(clicks/tones)

Optional Scent /
Airflow

Optional Heater
(mild warmth)

Power Module
(CC/CV, safety)

Station Sensors
(V,I,temp,presence)

Station Controller

For the robot, this appears as:

11.2 Mechanical Design and Contact Geometry

Goals:

Suggested features:

Thus, random or slightly biased approaches often result in a valid power connection. Once connected, geometry tends to stabilize pose and reduce unwanted motion.

11.3 Sensory Lures: Making the Station Salient

The station should emit multi-modal gradients that:

RF Lure

Robot side: RF is just rf_rssi_*. If “being near high RSSI with that pattern” tends to precede h_energy improvement, contingencies will favor moving into RF peaks.

Optical Lure

Robot side: appears in light_* sensors; spatial memory may encode “cells with this light distribution tend to be good for energy”.

Acoustic / Vibration Lure

Robot side: shows up as specific spectral signatures in microphone and/or IMU.

Scent / Airflow / Temperature Lure

Robot side: changes in gas, humidity, temperature co-vary with other station cues.

11.4 Power Delivery and Safety

Station-side power module:

Robot-side:

There is no explicit “start charging” RPC. Interaction is purely physical and sensory.

11.5 Signaling “Charging” vs “Done” Without Semantics

The station should change its sensory profile when:

  1. Charging is active,
  2. Charging is complete (or paused).

These differences must be clear in sensor space but not labeled in code.

Examples:

From the robot’s perspective:

Over many episodes, contingency and spatial memory can learn that:

Behavior then tends toward:

No code ever says if low_battery then go_to_station().

11.6 Station Logic (as implemented)

src/station is real, running code, not pseudocode. Its state machine (src/station/src/main.cpp):

charging_active = current > CURRENT_ACTIVE_THRESHOLD
if charging_active:
    state = "FULL" if (voltage > VOLTAGE_FULL_THRESHOLD and
                        current < CURRENT_TAPER_THRESHOLD) else "CHARGING"
else:
    state = "IDLE"

set_led_mode(state)     # slow pulse / fast pulse / solid
set_audio_mode(state)   # silent / periodic click / rare tick
# RF beacon is a fixed SoftAP SSID — it does NOT change with state.
# Near-field cues (LED, audio) carry state; RF only says "over here",
# a long-range gradient the robot can climb from a distance it can't
# yet see or hear the near-field cues from.

The robot never sees "CHARGING" or "FULL" as symbols; it only experiences the sensory shifts (LED/audio pattern up close, RF signal strength from a distance).

11.7 Charging as an Attractor — Hypothesis, Not Yet Observed

The design intent, unverified on real hardware (tracked as v0.9.1):

  1. Early life — robot wanders; occasionally contacts the station; battery_voltage rises; h_energy improves.
  2. Memory formation — contingency memory associates approach actions and station cues with h_energy improvement; spatial memory notes the location.
  3. Behavior shaping — when h_energy deviates, drives bias exploration toward patterns that historically improved it.
  4. Completion and departure — once charged, cues shift to "full", h_energy drive relaxes, and exploration reasserts itself.

If this works, an observer would describe the robot as "seeking the charger when low, leaving when full". On hardware that's still a claim about what the system is supposed to produce. In simulation (§15), steps 1–3 happen in some lives: the learned organism reaches the dock and stays. Step 4 does not: it stays docked when full. See research.md for what the hardware experiment would need to show.


12. Learned Reflexes

A small linear map from sensor channels to actuator channels, trained only by drive reduction (include/core/sensorimotor_policy.h). Nothing is pre-wired. If a light sensor on one side comes to drive the opposite wheel, it's because that wiring kept being followed by drive pressure falling: the organism built itself a Braitenberg vehicle.

sensor inputs
(each minus its 5 min mean)

constant 1

map: always

map: energy

map: safety

map: boredom ...

sum, each map scaled
by its drive (0..1)

reflex proposal
per actuator

e[k][i][j] <- λ·e + (1-λ)·gate_k·x_i·ξ_j        ξ = exploration sample on actuator j
w[k][i][j] += η·A·e                              A = reward normalized by its running mean and sd
w          *= (1 - decay·dt), |w| <= 1.5         unrewarded habits fade over ~1.5 h

Memory: 7 contexts × 17 inputs × 16 outputs × 2 floats, about 15 KB at the maximum channel counts. Weights are not yet part of the saved life-state.

What it has produced (simulator only, §15): from all-zero weights, backing away after bumps (the safety context learns reverse) and, in some lives, reaching and staying on the dock. Before sensory adaptation was added it also found a stereotypy: spinning in place, which kept the light sensors changing and so relieved boredom, like pacing in under-stimulated captive animals.


13. Life-State Persistence and Body Transfer

The organism's state is a downloadable/restorable JSON file (GET/POST /api/state), matching what src/esp32/src/net/dashboard_server.cpp actually serializes:

{
  "version": 1,
  "age_ms": 172800000,
  "channel_fingerprint": "a1b2c3d4",
  "physiology": { "h_energy": 0.73, "h_safety": 0.91, "...": "one entry per PhysVar" },
  "contingency": [
    { "actuators": { "motor_left": 1 }, "sensors": { "light": 1 },
      "ctx_hash": 12, "strength": 0.8, "mean_drive_delta": 0.05, "age": 40 }
  ],
  "spatial": [
    { "sensors": { "light": 2 }, "visit_count": 6, "age": 3,
      "drive_improvement": { "h_energy": 0.12 } }
  ]
}

Mechanism:


14. Implementation on ESP32-Class Hardware

14.1 Software Structure (as implemented)

One loop(), no FreeRTOS tasks, no interrupts:

PWM (hardware LEDC) +
ADC (Sensor::read(), cached)

Behavior tick
(~20 Hz, elapsed-millis gated)

Slow tick
(~1 Hz: StateLogger)

14.2 Memory and Timing Constraints

To stay microcontroller-friendly:


15. Experimental Program and Ablations

Run in simulation only (src/sim, which drives the unmodified engine in a modelled room; results and the model in its README). On hardware, none of this has been run. Currently measured: lifespan, time docked, coverage, distance to the station when hungry vs sated, and behavior entropy, per life, across repeated lives; ablations for reflexes, each memory, the battery link, and all memory, plus a no-engine random-walk baseline and a hand-wired upper bound. Headline: learned reflexes lift mean lifespan from 1.26 h to 1.86 h (hand-wired bound 2.46 h); nothing accumulates across lives yet.

The full program:

15.1 Metrics

15.2 Ablations

Comparing ablated and full agents isolates which mechanisms contribute most to diverse, legible behavior.