DEV Community

Mariano Gobea Alcoba
Mariano Gobea Alcoba

Posted on • Originally published at mgatc.com

The Lottery of Life!

Life simulations, often designed to explore the confluence of chance and agency, present a compelling domain for technical analysis. "The Lottery of Life," as described, encapsulates a system where player choices and stochastic events collectively shape a simulated individual's trajectory across various life dimensions. This article delves into the technical architecture required to implement such a system, focusing on core simulation mechanics, event generation, attribute management, and the computational modeling of choice and consequence.

Core Simulation Architecture

At its foundation, "The Lottery of Life" operates as a discrete-time simulation. The progression of a simulated life is broken down into distinct time steps, or "turns," each representing a period during which events occur, choices are made, and attributes are updated.

The Life Entity Model

The central entity in this simulation is the "Life Entity," representing the player's simulated self. This entity is defined by a comprehensive set of attributes that quantify various aspects of its existence. These attributes are not merely scalar values but often exhibit complex interdependencies and dynamic ranges.

Consider a foundational set of attributes:

  • Financial: money, debt, income, expenses, assets
  • Health: physical_health, mental_health, stress_level
  • Well-being: happiness, social_connections
  • Career/Skills: education_level, job_satisfaction, skill_points
  • Relationships: family_status, relationship_quality
  • Time: age, turns_remaining

Each attribute typically has a defined range (e.g., 0-100 for health/happiness, arbitrary for money), and may have thresholds that trigger specific events or states (e.g., physical_health < 20 could trigger a "critical illness" state).

A Python class can serve as a suitable representation for the Life Entity:

import uuid

class LifeEntity:
    def __init__(self, initial_age=18):
        self.entity_id = str(uuid.uuid4())
        self.age = initial_age
        self.turns_remaining = 100 # Example: simulation ends after 100 turns

        # Financial Attributes
        self.money = 1000.0
        self.debt = 0.0
        self.income = 0.0
        self.expenses = 100.0 # Base expenses per turn
        self.assets = {} # e.g., {'house': 200000}

        # Health Attributes (0-100 scale)
        self.physical_health = 80
        self.mental_health = 75
        self.stress_level = 30 # 0-100, higher is worse

        # Well-being Attributes (0-100 scale)
        self.happiness = 70
        self.social_connections = 50

        # Career/Skills Attributes
        self.education_level = 'High School'
        self.job_satisfaction = 60
        self.skill_points = 10

        # Relationships
        self.family_status = 'Single' # Could be 'Single', 'Married', 'Divorced'
        self.relationship_quality = 50 # Applicable if in a relationship

        # State flags or derived attributes
        self.is_employed = False
        self.has_illness = False
        self.events_taken = [] # Log of past events/choices

    def update_age(self, years=1):
        self.age += years
        self.turns_remaining -= years # Assuming each turn is a year

    def apply_financial_update(self):
        # Basic income/expense calculation
        self.money += self.income - self.expenses
        if self.money < 0:
            self.debt += abs(self.money) * 0.1 # Accumulate debt interest
            self.money = 0

    def check_health_thresholds(self):
        if self.physical_health < 20 and not self.has_illness:
            # Trigger a critical illness event
            print("CRITICAL: Physical health is severely low!")
            self.has_illness = True # Prevent multiple triggers

    def to_dict(self):
        return {attr: getattr(self, attr) for attr in dir(self) if not attr.startswith('__') and not callable(getattr(self, attr))}
Enter fullscreen mode Exit fullscreen mode

The Turn-Based Engine

The core loop orchestrates the progression of the simulation. Each turn involves several phases:

  1. Passive Attribute Updates: Recalculate and apply changes to attributes that accrue over time (e.g., age, base expenses, passive income).
  2. State-Dependent Adjustments: Adjust attributes based on interdependencies (e.g., high stress negatively impacts mental health).
  3. Event Generation: Determine and present new events to the player.
  4. Player Choice: Solicit and process player decisions for available events.
  5. Outcome Resolution: Apply the consequences of player choices and event outcomes.
  6. End-of-Turn Checks: Evaluate win/loss conditions, game termination, or trigger scheduled events.
class SimulationEngine:
    def __init__(self, life_entity, event_manager):
        self.life = life_entity
        self.event_manager = event_manager
        self.game_over = False

    def run_turn(self):
        if self.game_over:
            print("Simulation ended.")
            return

        print(f"\n--- Age: {self.life.age} ---")

        # Phase 1: Passive Attribute Updates
        self.life.update_age()
        self.life.apply_financial_update()

        # Phase 2: State-Dependent Adjustments
        self._apply_interdependencies()
        self.life.check_health_thresholds()

        # Phase 3 & 4: Event Generation & Player Choice
        possible_events = self.event_manager.get_eligible_events(self.life)
        if possible_events:
            event_to_handle = self.event_manager.select_event(possible_events)
            if event_to_handle:
                print(f"EVENT: {event_to_handle.description}")
                choice = self._get_player_choice(event_to_handle)
                if choice:
                    # Phase 5: Outcome Resolution
                    self.event_manager.resolve_event(event_to_handle, choice, self.life)
                    self.life.events_taken.append(event_to_handle.id)
                else:
                    print("No valid choice made for this event.")
        else:
            print("No significant events this turn.")

        # Phase 6: End-of-Turn Checks
        if self.life.turns_remaining <= 0 or self.life.physical_health <= 0 or self.life.mental_health <= 0:
            self.game_over = True
            print("Game Over condition met.")

        self._display_current_status()

    def _apply_interdependencies(self):
        # Example: High stress reduces mental and physical health
        if self.life.stress_level > 70:
            self.life.mental_health -= 5
            self.life.physical_health -= 2
        elif self.life.stress_level < 30:
            self.life.mental_health += 2
            self.life.physical_health += 1

        # Example: Low money increases stress
        if self.life.money < 500:
            self.life.stress_level += 5
        elif self.life.money > 5000:
            self.life.stress_level -= 2

        # Ensure attributes stay within bounds
        self.life.physical_health = max(0, min(100, self.life.physical_health))
        self.life.mental_health = max(0, min(100, self.life.mental_health))
        self.life.stress_level = max(0, min(100, self.life.stress_level))
        self.life.happiness = max(0, min(100, self.life.happiness))

    def _get_player_choice(self, event):
        print("Choices:")
        for i, choice in enumerate(event.choices):
            print(f"  {i+1}. {choice['description']}")

        while True:
            try:
                choice_index = int(input("Enter your choice number: ")) - 1
                if 0 <= choice_index < len(event.choices):
                    return event.choices[choice_index]
                else:
                    print("Invalid choice. Please try again.")
            except ValueError:
                print("Invalid input. Please enter a number.")

    def _display_current_status(self):
        print("\n--- Current Status ---")
        print(f"Money: ${self.life.money:.2f}, Debt: ${self.life.debt:.2f}")
        print(f"Health: P:{self.life.physical_health}, M:{self.life.mental_health}, Stress:{self.life.stress_level}")
        print(f"Happiness: {self.life.happiness}, Social: {self.life.social_connections}")
Enter fullscreen mode Exit fullscreen mode

The Event System

The event system is the primary mechanism for introducing variability and interactive elements into the simulation. It encompasses event definition, generation, and outcome resolution.

Event Definition and Structure

An event must encapsulate all necessary information for its presentation and processing. This includes a unique identifier, a descriptive text, a set of available choices, and the specific outcomes associated with each choice. Outcomes are typically attribute modifications, but can also trigger new events, set flags, or modify game rules.

class Event:
    def __init__(self, event_id, description, choices, prerequisites=None, type='general'):
        self.id = event_id
        self.description = description
        self.choices = choices # List of dicts, each with 'description' and 'outcomes'
        self.prerequisites = prerequisites if prerequisites is not None else {} # e.g., {'min_money': 500, 'min_age': 25}
        self.type = type

    def check_prerequisites(self, life_entity):
        for attr, value in self.prerequisites.items():
            current_attr_value = getattr(life_entity, attr, None)
            if current_attr_value is None:
                return False # Prerequisite attribute not found

            # Simple greater than/less than checks
            if isinstance(value, dict): # For more complex conditions, e.g., {'min': 500, 'max': 1000}
                if 'min' in value and current_attr_value < value['min']: return False
                if 'max' in value and current_attr_value > value['max']: return False
            elif current_attr_value < value: # Default: current value must be >= prerequisite value
                return False
        return True

# Example Event
event_template = {
    "id": "JOB_OFFER_STARTUP",
    "description": "You receive a job offer from a risky startup.",
    "choices": [
        {
            "description": "Accept the offer (high risk, high reward potential)",
            "outcomes": [
                {"attribute": "money", "change": 500, "chance": 0.7, "on_fail": -200},
                {"attribute": "job_satisfaction", "change": 20, "chance": 0.8, "on_fail": -10},
                {"attribute": "stress_level", "change": 15},
                {"attribute": "is_employed", "set_value": True},
                {"event_trigger": "STARTUP_SUCCESS_EVENT", "chance": 0.2},
                {"event_trigger": "STARTUP_FAILURE_EVENT", "chance": 0.4}
            ]
        },
        {
            "description": "Decline the offer (maintain current stability)",
            "outcomes": [
                {"attribute": "happiness", "change": 5},
                {"attribute": "stress_level", "change": -5}
            ]
        }
    ],
    "prerequisites": {
        "education_level": "University",
        "age": {"min": 22, "max": 35}
    }
}
Enter fullscreen mode Exit fullscreen mode

Event Generation and Selection

Events can be generated in various ways:

  1. Purely Random: Events are drawn uniformly from a pool.
  2. Weighted Random: Events have different probabilities of being selected, potentially based on life_entity state.
  3. State-Dependent: Events are only eligible if specific conditions (prerequisites) on the life_entity attributes are met.
  4. Sequential/Chained: One event's outcome triggers a subsequent, specific event.
  5. Scheduled: Events occur at predefined age or turn numbers.

An EventManager class manages the pool of all possible events and determines which ones are eligible and presented to the player.

import random

class EventManager:
    def __init__(self, event_data_list):
        self.all_events = {event['id']: Event(**event) for event in event_data_list}
        self.event_weights = {event['id']: 1 for event in event_data_list} # Default uniform weight

    def get_eligible_events(self, life_entity):
        eligible = []
        for event_id, event in self.all_events.items():
            if event.check_prerequisites(life_entity) and event_id not in life_entity.events_taken:
                eligible.append(event)
        return eligible

    def select_event(self, eligible_events, num_to_select=1):
        if not eligible_events:
            return None

        # Simple selection: pick one at random from eligible events
        # More complex: weight by type, player's current 'luck' attribute, etc.
        return random.choice(eligible_events)

    def resolve_event(self, event, chosen_option, life_entity):
        for outcome in chosen_option['outcomes']:
            if 'attribute' in outcome:
                change = outcome['change']
                # Handle probabilistic outcomes for attribute changes
                if 'chance' in outcome:
                    if random.random() < outcome['chance']:
                        setattr(life_entity, outcome['attribute'], getattr(life_entity, outcome['attribute']) + change)
                    else:
                        # Apply 'on_fail' if specified and probabilistic outcome fails
                        if 'on_fail' in outcome:
                            setattr(life_entity, outcome['attribute'], getattr(life_entity, outcome['attribute']) + outcome['on_fail'])
                else:
                    # Direct change
                    setattr(life_entity, outcome['attribute'], getattr(life_entity, outcome['attribute']) + change)

            if 'set_value' in outcome:
                setattr(life_entity, outcome['attribute'], outcome['set_value'])

            if 'event_trigger' in outcome:
                if 'chance' in outcome:
                    if random.random() < outcome['chance']:
                        # This would queue a new event for the next turn or immediate processing
                        print(f"DEBUG: Triggering chained event: {outcome['event_trigger']}")
                else:
                    print(f"DEBUG: Triggering chained event: {outcome['event_trigger']}")

        # Ensure all attributes stay within their defined bounds after changes
        life_entity.physical_health = max(0, min(100, life_entity.physical_health))
        life_entity.mental_health = max(0, min(100, life_entity.mental_health))
        life_entity.stress_level = max(0, min(100, life_entity.stress_level))
        life_entity.happiness = max(0, min(100, life_entity.happiness))
        life_entity.job_satisfaction = max(0, min(100, life_entity.job_satisfaction))
        life_entity.relationship_quality = max(0, min(100, life_entity.relationship_quality))
Enter fullscreen mode Exit fullscreen mode

This EventManager demonstrates a mechanism for applying outcomes. A more sophisticated system might use a dispatcher pattern to handle different outcome types (e.g., AttributeChangeOutcome, EventTriggerOutcome) as distinct objects, promoting extensibility.

Attribute Management and Interdependencies

The realism and strategic depth of the simulation heavily rely on the intricate relationships between attributes. Attributes rarely exist in isolation; a change in one often cascades to others.

Attribute Modeling

Beyond simple numerical values, attributes can possess additional properties:

  • Min/Max Bounds: Defines the operational range (e.g., health cannot go below 0 or above 100).
  • Decay/Growth Rates: Some attributes might naturally decay (e.g., happiness, physical health) or grow (e.g., age, skill points) over time.
  • Severity Tiers: Health might have "minor," "serious," "critical" states, each triggering different effects or event probabilities.

Implementing these can be achieved by adding helper methods to the LifeEntity or by defining a separate Attribute class.

class LifeAttribute:
    def __init__(self, name, value, min_val=0, max_val=100, decay_rate=0, affects=None):
        self.name = name
        self._value = value
        self.min_val = min_val
        self.max_val = max_val
        self.decay_rate = decay_rate # Per turn decay
        self.affects = affects if affects is not None else {} # {attribute_name: {'modifier': 0.1, 'threshold': 70}}

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, new_value):
        self._value = max(self.min_val, min(self.max_val, new_value))

    def apply_decay(self):
        self.value -= self.decay_rate

    def get_impact_on_others(self):
        impacts = {}
        for affected_attr, rules in self.affects.items():
            if 'threshold' in rules and self.value >= rules['threshold']:
                impacts[affected_attr] = rules['modifier']
            elif 'threshold' not in rules: # Always affects
                 impacts[affected_attr] = rules['modifier']
        return impacts
Enter fullscreen mode Exit fullscreen mode

The LifeEntity would then manage a dictionary of LifeAttribute objects instead of raw numbers. This increases complexity but provides a more robust and configurable system for attribute behavior.

Inter-Attribute Dynamics

The heart of a compelling life simulation lies in its interdependencies. These relationships model how different facets of life influence one another. Examples include:

  • Stress and Health: High stress_level directly depletes physical_health and mental_health.
  • Money and Happiness: Low money can reduce happiness and increase stress_level. Conversely, high money might reduce stress_level but not necessarily increase happiness beyond a certain point.
  • Education and Career: Higher education_level unlocks better job income and job_satisfaction opportunities.
  • Relationships and Mental Health: Strong social_connections and positive relationship_quality can bolster mental_health and happiness.

These dependencies can be modeled using:

  1. Direct Modifiers: Simple additive or subtractive changes based on thresholds (as shown in _apply_interdependencies).
  2. Functions: More complex non-linear relationships, where the impact is calculated using a mathematical function (e.g., a sigmoid function for happiness saturation).
  3. Conditional Event Triggers: Specific attribute states (e.g., money < -1000) might trigger unique "crisis" events.

A dedicated "dependency manager" or integrating these rules into the SimulationEngine's turn logic is crucial. This logic ensures that attribute changes propagate throughout the system consistently.

# Example of a more explicit dependency update (could be moved to LifeEntity or a separate manager)
def update_attribute_dependencies(life_entity):
    # Stress impacts health
    if life_entity.stress_level > 70:
        life_entity.physical_health -= 5 * ((life_entity.stress_level - 70) / 30) # Scaled impact
        life_entity.mental_health -= 10 * ((life_entity.stress_level - 70) / 30)
    elif life_entity.stress_level < 30:
        life_entity.physical_health += 1
        life_entity.mental_health += 2

    # Financial stress
    if life_entity.money < 0:
        life_entity.stress_level += 5
        life_entity.happiness -= 3
    elif life_entity.money > 10000:
        life_entity.stress_level -= 2
        life_entity.happiness += 1 # Diminishing returns on happiness

    # Physical health impacts mental health
    if life_entity.physical_health < 40:
        life_entity.mental_health -= 5

    # Social connections and happiness
    if life_entity.social_connections < 20:
        life_entity.happiness -= 5
        life_entity.mental_health -= 3
    elif life_entity.social_connections > 80:
        life_entity.happiness += 5
        life_entity.mental_health += 3

    # Ensure all attributes are clamped to their valid ranges after all updates
    life_entity.physical_health = max(0, min(100, life_entity.physical_health))
    life_entity.mental_health = max(0, min(100, life_entity.mental_health))
    life_entity.stress_level = max(0, min(100, life_entity.stress_level))
    life_entity.happiness = max(0, min(100, life_entity.happiness))
    life_entity.job_satisfaction = max(0, min(100, life_entity.job_satisfaction))
    life_entity.relationship_quality = max(0, min(100, life_entity.relationship_quality))

# Integrate this into SimulationEngine.run_turn()
# In SimulationEngine._apply_interdependencies():
#   update_attribute_dependencies(self.life)
Enter fullscreen mode Exit fullscreen mode

Decision Making and Strategic Depth

The "Lottery of Life" is not purely deterministic; it introduces a layer of player agency through choices. The technical design must facilitate meaningful decisions where players weigh risks and rewards, often under uncertainty.

Choice Impact Modeling

Each choice presented to the player results in a set of outcomes. These outcomes can be:

  • Deterministic: Always happen as described.
  • Probabilistic: Have a chance of happening, potentially with alternative outcomes on failure (on_fail).
  • Conditional: Only apply if certain life_entity conditions are met.
  • Delayed: Outcomes that manifest after several turns or trigger future events.

The outcomes structure within an Event needs to support these variations. Probabilistic outcomes are crucial for modeling the "lottery" aspect, where even well-intentioned choices might not yield the desired result.

{
    "attribute": "money",
    "change": 1000,
    "chance": 0.6,
    "on_fail": -500,
    "description_success": "Your investment paid off!",
    "description_failure": "The investment tanked."
}
Enter fullscreen mode Exit fullscreen mode

The EventManager.resolve_event method would interpret these parameters to apply changes. A robust outcome system would allow for multiple parallel probabilistic outcomes from a single choice, each resolved independently.

Risk/Reward Balancing

The design of events and choices naturally creates a risk/reward dynamic. Technically, this translates to:

  • Variance in Outcomes: High-risk choices might offer large positive attribute changes (change) with low chance, or large negative on_fail values. Low-risk choices would have smaller changes but higher certainties.
  • Attribute Cost/Benefit: Choices often require a trade-off (e.g., spending money for happiness, sacrificing health for money).
  • Hidden Information: The game might deliberately obscure exact probabilities or future consequences, simulating real-world uncertainty. This could be achieved by only displaying qualitative descriptions (e.g., "potentially lucrative, but risky") rather than exact percentages.

Long-Term Strategy vs. Short-Term Gains

A well-designed simulation encourages diverse player strategies. This requires:

  • Delayed Consequences: Some choices should not have immediate, obvious impacts but rather set up conditions for future events or subtly shift attribute trajectories over many turns. For instance, education_level or skill_points investments might not pay off for several years.
  • Resource Management: Players must balance immediate needs (e.g., money for expenses) against long-term goals (e.g., saving for assets, improving health).
  • Emergent Narratives: The interplay of player choices and random events should ideally create unique "life stories" rather than a linear path. This implies a large, diverse event pool and flexible event chaining.

Technical Implementation Considerations

Building such a simulation involves practical considerations for data management, randomness, and maintainability.

Data Structures for Events and Attributes

Using data-driven design is paramount for manageability. Events, choices, and their outcomes should be defined in external files (e.g., JSON, YAML) rather than hardcoded. This allows for easy expansion, balancing, and modding.

# events.yaml
- id: "INVEST_STOCK_MARKET"
  description: "You have some spare capital. Consider investing in the stock market."
  prerequisites:
    money: {"min": 500}
  choices:
    - description: "Invest a significant amount (high risk)"
      outcomes:
        - attribute: "money"
          change: 2000
          chance: 0.3
          on_fail: -1000
        - attribute: "stress_level"
          change: 10
    - description: "Invest a small amount (moderate risk)"
      outcomes:
        - attribute: "money"
          change: 300
          chance: 0.6
          on_fail: -100
        - attribute: "stress_level"
          change: 5
    - description: "Don't invest"
      outcomes:
        - attribute: "happiness"
          change: 2
Enter fullscreen mode Exit fullscreen mode

This YAML data can be loaded and


Originally published in Spanish at www.mgatc.com/blog/the-lottery-of-life/

Top comments (0)