Skip to main content
The Crimsonland rewrite uses a classic update-then-render game loop with deterministic simulation and presentation-phase separation.

High-Level Flow

Frame Pipeline

Each frame executes in three phases:
1

Input Collection

Gather keyboard, mouse, gamepad inputs and build PlayerInput frames.
2

Deterministic Update

Run WorldState.step() with normalized inputs to advance simulation.
3

Presentation

Apply audio/visual effects from deterministic presentation commands.
4

Render

Draw terrain, sprites, UI, and effects to screen.

View Protocol

The rewrite uses a simple View protocol for game screens:
src/grim/view.py
class View(Protocol):
    """Pluggable view for raylib window."""
    
    def open(self) -> None:
        """Called once after window init."""
        ...
    
    def update(self, dt: float) -> None:
        """Called each frame before drawing."""
        ...
    
    def draw(self) -> None:
        """Called each frame inside BeginDrawing/EndDrawing."""
        ...
    
    def close(self) -> None:
        """Called once before window closes."""
        ...

Main Loop Implementation

The core loop lives in src/grim/app.py:
src/grim/app.py
def run_view(
    view: View,
    *,
    width: int = 1280,
    height: int = 720,
    fps: int = 60,
) -> None:
    """Run a Raylib window with a pluggable view."""
    rl.init_window(width, height, "Crimsonland")
    rl.set_target_fps(fps)
    
    view.open()
    
    while not rl.window_should_close():
        dt = rl.get_frame_time()
        
        # Update phase
        view.update(dt)
        
        # Render phase
        rl.begin_drawing()
        view.draw()
        rl.end_drawing()
    
    view.close()
    rl.close_window()

Gameplay Mode Loop

Game modes implement the View protocol. Example from Survival:
src/crimson/modes/survival_mode.py
class SurvivalMode:
    def __init__(self, world: GameWorld):
        self.world = world
        self.session = SurvivalDeterministicSession(...)
    
    def update(self, dt: float) -> None:
        # Collect inputs from all players
        inputs = self.collect_player_inputs()
        
        # Run deterministic step
        result = self.session.step_tick(
            dt=dt,
            inputs=inputs,
        )
        
        # Apply presentation commands
        self.apply_presentation(result)
        
        # Update elapsed time
        self.elapsed_ms += result.dt_sim * 1000.0
    
    def draw(self) -> None:
        # Draw world (terrain, sprites)
        self.world.renderer.draw()
        
        # Draw HUD overlay
        draw_hud(self.world.state, self.world.players)
        
        # Draw perk selection UI if pending
        if self.world.state.perk_selection.pending_count > 0:
            draw_perk_menu(...)

Deterministic Step

The core deterministic tick runs in src/crimson/sim/world_state.py:
src/crimson/sim/world_state.py
class WorldState:
    def step(
        self,
        dt: float,
        *,
        inputs: list[PlayerInput],
        # ... other params
    ) -> WorldEvents:
        """Run one deterministic simulation tick."""
        
        # 1. Apply world-dt hooks (Reflex Boost time scaling)
        dt_sim = self.apply_world_dt_steps(dt)
        
        # 2. Run perk effects (Evil Eyes, Doctor, Jinxed, etc.)
        perks_update_effects(self.state, self.players, ...)
        
        # 3. Update player movement, aim, firing, reload
        for player, input in zip(self.players, inputs):
            player_update(player, self.state, dt_sim, input)
        
        # 4. Update projectiles (movement, hit detection)
        self.state.projectiles.update(...)
        
        # 5. Update creatures (AI, movement, damage)
        deaths = self.creatures.update(...)
        
        # 6. Update bonuses (pickup detection, timers)
        pickups = bonus_update(self.state, self.players, ...)
        
        # 7. Handle player deaths (Final Revenge, etc.)
        self.apply_player_death_hooks()
        
        # 8. Update effects (camera shake, FX timers)
        self.state.effects.update(dt_sim)
        
        return WorldEvents(
            hits=hits,
            deaths=deaths,
            pickups=pickups,
            sfx=sfx_events,
        )
All RNG draws happen inside WorldState.step() using state.rng. This ensures deterministic replay.

Presentation Phase

After the deterministic step, presentation commands are planned:
src/crimson/sim/step_pipeline.py
def run_deterministic_step(
    world: WorldState,
    timing: FrameTiming,
    inputs: list[PlayerInput],
    ...
) -> DeterministicStepResult:
    # 1. Run deterministic simulation
    events = world.step(timing.dt_sim, inputs=inputs, ...)
    
    # 2. Plan presentation commands (SFX, music triggers)
    presentation = apply_world_presentation_step(
        state=world.state,
        players=world.players,
        hits=events.hits,
        deaths=events.deaths,
        pickups=events.pickups,
        ...
    )
    
    # 3. Compute command hash for replay verification
    command_hash = presentation_commands_hash(presentation)
    
    return DeterministicStepResult(
        dt_sim=timing.dt_sim,
        events=events,
        presentation=presentation,
        command_hash=command_hash,
    )
Presentation commands include:
  • SFX keys — Ordered list of sound effects to play
  • Music triggers — Game tune start (first hit in Survival)
  • Visual effects — Decals, rings, particles
Presentation commands must be deterministic. Use state.rng for any randomization, not random.random().

Frame Timing

Frame timing is handled by FrameTiming struct:
src/crimson/sim/timing.py
class FrameTiming(msgspec.Struct):
    dt_sim: float           # Simulation delta (after Reflex Boost)
    dt_player_local: float  # Player input delta (unscaled)
    
    @classmethod
    def build(
        cls,
        dt: float,
        *,
        reflex_boost_timer: float,
        time_scale_active: bool,
    ) -> FrameTiming:
        # Convert to float32 for parity
        dt_f32 = f32(dt)
        
        # Apply Reflex Boost time scaling
        if time_scale_active and reflex_boost_timer > 0:
            scale = time_scale_reflex_boost_factor(
                reflex_boost_timer=reflex_boost_timer,
                time_scale_active=True,
            )
            dt_sim = f32(dt_f32 * scale)
        else:
            dt_sim = dt_f32
        
        return cls(
            dt_sim=dt_sim,
            dt_player_local=dt_f32,
        )

Reflex Boost Time Scaling

When Reflex Boost is active, time slows down:
def time_scale_reflex_boost_factor(
    reflex_boost_timer: float,
    time_scale_active: bool,
) -> float:
    if not time_scale_active:
        return 1.0
    
    reflex_f32 = f32(reflex_boost_timer)
    time_scale_factor = f32(0.3)  # 30% speed
    
    # Fade in over first second
    if reflex_f32 < 1.0:
        time_scale_factor = f32((1.0 - reflex_f32) * 0.7 + 0.3)
    
    return time_scale_factor

Update/Render Separation

The rewrite strictly separates update and render:
  • Collect inputs
  • Run deterministic simulation
  • Plan presentation commands
  • Update timers and counters
  • No raylib drawing calls
This separation enables headless replay verification and ensures rendering bugs don’t affect simulation.

Screenshot Support

The game loop includes built-in screenshot capture:
# Press F12 to capture
if rl.is_key_pressed(rl.KeyboardKey.KEY_F12):
    rl.take_screenshot(f"screenshots/{index:05d}.png")

FPS Control

The loop respects the target FPS via raylib:
rl.set_target_fps(60)  # Target 60 FPS
dt = rl.get_frame_time()  # Actual delta time
For replay verification, use fixed timestep:
TICK_RATE = 60
FIXED_DT = 1.0 / TICK_RATE  # 0.0166... seconds

Next Steps

Deterministic Pipeline

Deep dive into the step contract

Gameplay System

Explore gameplay mechanics

Rendering System

Learn about rendering

Replay Module

Understand replay recording