Skip to content

Design ‐ Local Time Refactor

Pier-Luc Brault edited this page May 16, 2026 · 5 revisions

Local Time Refactor

Implemented by #222.

Problem

The current_time value passed to scenes and modals via their update method is derived from pygame.time.get_ticks(), which returns milliseconds since pygame was initialized. This is a global clock — it does not reset when a new scene starts, when a scene is reset, or when a modal is shown.

This causes bugs, most notably in forced process creation. The force_new_standard_process_at_times_ms and force_new_priority_process_at_times_ms configuration values represent offsets from the start of the stage, but they are compared directly against current_time. If the player has been in the game for any significant duration before reaching or resetting a stage, all forced processes with times lower than the current global time will be created immediately on stage startup.

More broadly, any game logic that relies on current_time being relative to the start of a scene or modal is fragile by nature. This refactor establishes a proper local time model that makes these relationships explicit and correct.

Terminology

Global time — The time derived from pygame.time.get_ticks(). It is monotonically increasing and never resets.

Local time — The time relative to the start of a context. A context is either a scene or a modal. Each context has its own local time that starts at 0 when the context becomes active. Local time does not advance while the context is paused (i.e. while a child context is on top of it).

Context — A scene or modal that is currently active, paused, or on the context stack. The topmost context on the stack is the one that receives update calls; all others are paused. All contexts implement the GameObject interface.

GameObject Interface

A new abstract class GameObject will be introduced as a parent to both Scene and SceneObject. This class defines the common interface required for any object that can be a context on the context stack.

Interface:

  • update(current_time, events) — abstract method that updates the object and its children

Both Scene and SceneObject already implement this method, so the refactor primarily involves:

  1. Creating the GameObject ABC
  2. Making Scene and SceneObject inherit from GameObject
  3. Updating the context stack to hold GameObject references

Note: render() is intentionally not part of this interface. While both Scene and SceneObject have render methods, their signatures differ (Scene.render() takes no arguments and uses self.screen, while SceneObject.render(surface) takes a surface argument).

Context Stack Model

Each entry in the context stack consists of:

  • A reference to the GameObject (scene or modal)
  • The global time at which this context became the top context
  • The accumulated time this context has been paused (i.e. had a child context on top of it)

Local time for the top context is calculated as:

local_time = global_time − start_time − paused_time

Push (a modal is shown or a new scene starts):

  • A new entry is appended to the stack with the global time passed to the method as start_time and paused_time of 0
  • The parent context is implicitly paused — its paused_time will be updated when the child context is popped

Pop (a modal is closed):

  • The top entry is removed from the stack
  • The pause duration is calculated as: global_time − popped_entry.start_time
  • The new top context's paused_time is incremented by this pause duration

An important property: only local time for the top context is ever needed. Paused time for non-top contexts is updated lazily as a side effect when the context above them is popped. There is no need to proactively update paused_time for paused contexts.

Current Behavior Preserved

The refactor preserves the following existing behaviors:

  • When a modal is shown, the scene's local time freezes and does not advance until the modal is closed
  • While a modal is open, it receives a monotonically increasing local time each frame
  • When the scene is reset (via start_scene or reset), its local time starts at 0

Changes by Component

SceneManager

SceneManager becomes the authority on local time and context tracking, and takes over the orchestration responsibilities currently in GameManager.step().

New responsibilities:

  • Maintain the context stack
  • Provide update(global_time, events) which:
    1. Stores global_time internally for the duration of the frame
    2. Determines the active context (top of stack)
    3. Computes local time from global time
    4. Calls update(local_time, events) on the active context
    5. If the active context changed during the update (e.g., scene switch), updates the new active context with empty events
  • Provide push_context(context) to add a new context to the stack, using the stored global_time
  • Provide pop_context() to remove the top context and update the new top's paused_time, using the stored global_time
  • Provide reset_current_context_time() to reset the top context's local time to 0 (used when a scene is reset), using the stored global_time

The existing start_scene method will be updated to replace the current context stack with a single new entry for the scene, using the stored global time as start_time and setting paused_time to 0. start_scene will accept an optional global_time parameter for cases when it is called outside the update cycle (e.g., during initial game setup). If called during the update cycle, the stored global time is used.

GameManager

GameManager is simplified significantly.

The following fields and logic are removed:

  • _frozen_time — no longer needed (context stack handles pausing)
  • _pause_raw_time — no longer needed
  • _total_paused_time — no longer needed (each context tracks its own paused_time)
  • _last_adjusted_time — no longer needed
  • _get_adjusted_time() — no longer needed

The step() method is removed entirely. Its responsibilities move to SceneManager.update().

_main_loop is updated to call self._scene_manager.update(pygame.time.get_ticks(), events) instead of self.step(scene, events), then render the current scene.

Scene

  • show_modal() calls self.scene_manager.push_context(modal) after the existing UI setup
  • close_modal() calls self.scene_manager.pop_context() before the existing UI cleanup, so that the pause duration is calculated while the modal is still the top context
  • reset() calls self.scene_manager.reset_current_context_time() if a scene manager is available (it may not be during unit tests)

Scene remains unaware of global time. SceneManager stores the global time when update() is called, and uses that stored value when Scene calls push_context(), pop_context(), or reset_current_context_time().

Stage and Game Logic

No changes are required to Stage, ProcessManager, or any other scene object. They receive current_time as before, and the value is now correctly local. The forced process creation bug is fixed as a side effect: when a stage starts or resets, current_time will be 0.

Modal

No changes required. The close() method continues to call self.scene.close_modal(), which now also pops the context from the stack.

Scene Reset with Local Time

When scene.reset() is called (either directly or via start_scene), reset_current_context_time() is called, which resets the top context's start_time to the stored global time and paused_time to 0. This makes local time restart from 0.

Scene Switching

When start_scene() is called (switching to a different scene), the context stack is replaced with a single new entry. This is the correct behavior: the previous scene and its modal (if any) are discarded, and the new scene starts at local time 0.

Modal Opening

When a modal is shown:

  1. A new context is pushed onto the stack with start_time = the stored global time and paused_time = 0
  2. The parent context (the scene or a future parent modal) is implicitly paused
  3. The modal receives local time starting at 0

Modal Closing

When a modal is closed:

  1. The modal's start_time and the stored global time are used to compute how long the modal was active
  2. This duration is added to the parent context's paused_time
  3. The modal context is popped from the stack
  4. The parent context resumes with its local time continuing from where it left off

Future: Multiple Modals

This design naturally supports multiple stacked modals. When modal B is shown on top of modal A:

  1. Modal B is pushed onto the stack
  2. Modal A is implicitly paused (its paused_time will be incremented when B is popped)

When modal B is closed:

  1. B's duration is added to A's paused_time
  2. A resumes with correct local time

No changes to the time logic will be required when multiple modals are implemented — only the removal of the single-modal restriction in Scene.show_modal().

Affected Tests

Tests in test_game_manager.py that verify time behavior will need to be updated:

  • Scene local time now starts at 0 when a scene becomes active, rather than matching the global pygame time
  • Modal local time now starts at 0 when a modal is shown
  • Time values after modal close are recalculated based on local time semantics

The forced process creation tests in test_automation.py may also need adjustment if they relied on global time semantics.