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:
Input Collection
Gather keyboard, mouse, gamepad inputs and build PlayerInput frames.
Deterministic Update
Run WorldState.step() with normalized inputs to advance simulation.
Presentation
Apply audio/visual effects from deterministic presentation commands.
Render
Draw terrain, sprites, UI, and effects to screen.
View Protocol
The rewrite uses a simple View protocol for game screens:
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:
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:
Update Phase
Render Phase
- Collect inputs
- Run deterministic simulation
- Plan presentation commands
- Update timers and counters
- No raylib drawing calls
- Draw terrain
- Draw sprites (players, creatures, projectiles)
- Draw effects (decals, particles)
- Draw UI (HUD, menus)
- No gameplay state changes
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