Skip to main content
The input system collects keyboard, mouse, and gamepad input and normalizes it for multiplayer gameplay.

Architecture

  • src/grim/input.py — Raw input wrapper (raylib)
  • src/crimson/local_input.py — Local input collection and schemes
  • src/crimson/input_codes.py — Input code constants
  • src/crimson/sim/input.py — Player input struct
  • src/crimson/sim/input_frame.py — Multiplayer input normalization

Input Flow

1

Raw Input Collection

Collect keyboard/mouse/gamepad state from raylib.
2

Input Mapping

Map raw inputs to player actions (move, fire, reload).
3

Input Frame Build

Package inputs into PlayerInput structs.
4

Input Frame Normalization

Normalize for fixed player count (multiplayer).
5

Simulation

Pass normalized inputs to WorldState.step().

Player Input Struct

src/crimson/sim/input.py
class PlayerInput(msgspec.Struct):
    """Per-player input for one frame."""
    
    # Movement
    up: bool = False
    down: bool = False
    left: bool = False
    right: bool = False
    
    # Combat
    fire: bool = False
    reload: bool = False
    
    # Aim (mouse/analog stick)
    aim_x: float = 0.0
    aim_y: float = 0.0
    
    # UI
    perk_select_1: bool = False
    perk_select_2: bool = False
    perk_select_3: bool = False
    perk_select_4: bool = False

Input Collection

Keyboard + Mouse

src/crimson/local_input.py
def collect_keyboard_mouse_input(
    player_config: PlayerConfig,
) -> PlayerInput:
    """Collect keyboard + mouse input for one player."""
    
    # Movement keys
    up = rl.is_key_down(player_config.key_up)
    down = rl.is_key_down(player_config.key_down)
    left = rl.is_key_down(player_config.key_left)
    right = rl.is_key_down(player_config.key_right)
    
    # Combat keys
    fire = rl.is_mouse_button_down(rl.MOUSE_LEFT_BUTTON)
    reload = rl.is_key_down(player_config.key_reload)
    
    # Mouse aim
    mouse = rl.get_mouse_position()
    aim_x = float(mouse.x)
    aim_y = float(mouse.y)
    
    # Perk selection
    perk_select_1 = rl.is_key_pressed(rl.KEY_ONE)
    perk_select_2 = rl.is_key_pressed(rl.KEY_TWO)
    perk_select_3 = rl.is_key_pressed(rl.KEY_THREE)
    perk_select_4 = rl.is_key_pressed(rl.KEY_FOUR)
    
    return PlayerInput(
        up=up,
        down=down,
        left=left,
        right=right,
        fire=fire,
        reload=reload,
        aim_x=aim_x,
        aim_y=aim_y,
        perk_select_1=perk_select_1,
        perk_select_2=perk_select_2,
        perk_select_3=perk_select_3,
        perk_select_4=perk_select_4,
    )

Gamepad

def collect_gamepad_input(
    player_index: int,
    player_config: PlayerConfig,
) -> PlayerInput:
    """Collect gamepad input for one player."""
    
    gamepad_id = player_config.gamepad_id
    
    if not rl.is_gamepad_available(gamepad_id):
        return PlayerInput()  # Empty input
    
    # D-pad / left stick for movement
    left_stick_x = rl.get_gamepad_axis_movement(
        gamepad_id,
        rl.GAMEPAD_AXIS_LEFT_X,
    )
    left_stick_y = rl.get_gamepad_axis_movement(
        gamepad_id,
        rl.GAMEPAD_AXIS_LEFT_Y,
    )
    
    up = left_stick_y < -0.3 or rl.is_gamepad_button_down(
        gamepad_id, rl.GAMEPAD_BUTTON_LEFT_FACE_UP
    )
    down = left_stick_y > 0.3 or rl.is_gamepad_button_down(
        gamepad_id, rl.GAMEPAD_BUTTON_LEFT_FACE_DOWN
    )
    left = left_stick_x < -0.3 or rl.is_gamepad_button_down(
        gamepad_id, rl.GAMEPAD_BUTTON_LEFT_FACE_LEFT
    )
    right = left_stick_x > 0.3 or rl.is_gamepad_button_down(
        gamepad_id, rl.GAMEPAD_BUTTON_LEFT_FACE_RIGHT
    )
    
    # Right stick for aim
    right_stick_x = rl.get_gamepad_axis_movement(
        gamepad_id,
        rl.GAMEPAD_AXIS_RIGHT_X,
    )
    right_stick_y = rl.get_gamepad_axis_movement(
        gamepad_id,
        rl.GAMEPAD_AXIS_RIGHT_Y,
    )
    
    # Convert stick to aim position
    aim_x, aim_y = stick_to_aim(
        right_stick_x,
        right_stick_y,
        player_pos=...,
    )
    
    # Triggers for fire/reload
    fire = rl.is_gamepad_button_down(
        gamepad_id,
        rl.GAMEPAD_BUTTON_RIGHT_TRIGGER_1,
    )
    reload = rl.is_gamepad_button_down(
        gamepad_id,
        rl.GAMEPAD_BUTTON_LEFT_TRIGGER_1,
    )
    
    return PlayerInput(
        up=up,
        down=down,
        left=left,
        right=right,
        fire=fire,
        reload=reload,
        aim_x=aim_x,
        aim_y=aim_y,
    )

Aim Schemes

The game supports multiple aim schemes:
src/crimson/aim_schemes.py
class AimScheme(enum.Enum):
    MOUSE = 0          # Mouse cursor position
    DIRECTION = 1      # Last movement direction
    ANALOG = 2         # Gamepad right stick
    HYBRID = 3         # Direction + analog

Mouse Aim

def calculate_aim_angle_mouse(
    player_pos: Vec2,
    mouse_x: float,
    mouse_y: float,
) -> float:
    """Calculate aim angle from mouse position."""
    dx = mouse_x - player_pos.x
    dy = mouse_y - player_pos.y
    return math.atan2(dy, dx)

Direction Aim

def calculate_aim_angle_direction(
    player: PlayerState,
    input: PlayerInput,
) -> float:
    """Calculate aim angle from movement direction."""
    
    # Use movement direction
    dx = 0.0
    dy = 0.0
    if input.up:
        dy -= 1.0
    if input.down:
        dy += 1.0
    if input.left:
        dx -= 1.0
    if input.right:
        dx += 1.0
    
    if dx == 0.0 and dy == 0.0:
        # No movement - keep previous aim
        return player.aim_angle
    
    return math.atan2(dy, dx)

Input Frame Normalization

For multiplayer, input frames are normalized to fixed player count:
src/crimson/sim/input_frame.py
class InputFrame(msgspec.Struct):
    """Fixed-size input frame for deterministic replay."""
    player_count: int
    inputs: tuple[PlayerInput, PlayerInput, PlayerInput, PlayerInput]
    
    @classmethod
    def from_list(
        cls,
        inputs: list[PlayerInput],
        player_count: int,
    ) -> InputFrame:
        # Pad to 4 players
        padded = list(inputs)
        while len(padded) < 4:
            padded.append(PlayerInput())
        
        return cls(
            player_count=player_count,
            inputs=tuple(padded[:4]),
        )
    
    def as_list(self) -> list[PlayerInput]:
        return list(self.inputs[:self.player_count])

def normalize_input_frame(
    inputs: list[PlayerInput] | None,
    player_count: int,
) -> InputFrame:
    """Normalize inputs for fixed player count."""
    if inputs is None:
        inputs = [PlayerInput() for _ in range(player_count)]
    return InputFrame.from_list(inputs, player_count)
Input frames are always 4 players for deterministic replay, even in single-player. Unused slots have empty input.

Input Recording

For replays, inputs are recorded each frame:
src/crimson/replay/recorder.py
class ReplayRecorder:
    def record_frame(
        self,
        inputs: list[PlayerInput],
    ) -> None:
        """Record one frame of inputs."""
        
        # Normalize and encode
        frame = InputFrame.from_list(
            inputs,
            player_count=self.player_count,
        )
        
        encoded = encode_input_frame(frame)
        self.frames.append(encoded)

Multiplayer Input

Local Multiplayer

def collect_local_multiplayer_inputs(
    player_configs: list[PlayerConfig],
) -> list[PlayerInput]:
    """Collect inputs for all local players."""
    
    inputs = []
    for i, config in enumerate(player_configs):
        if config.input_type == InputType.KEYBOARD_MOUSE:
            input = collect_keyboard_mouse_input(config)
        elif config.input_type == InputType.GAMEPAD:
            input = collect_gamepad_input(i, config)
        else:
            input = PlayerInput()
        
        inputs.append(input)
    
    return inputs

Online Multiplayer

Online inputs use lockstep or rollback:
src/crimson/net/rollback.py
def collect_network_inputs(
    local_input: PlayerInput,
    remote_inputs: dict[int, PlayerInput],
    player_count: int,
) -> list[PlayerInput]:
    """Collect inputs from local + network."""
    
    inputs = [PlayerInput() for _ in range(player_count)]
    
    # Local player
    inputs[local_player_index] = local_input
    
    # Remote players
    for player_index, input in remote_inputs.items():
        inputs[player_index] = input
    
    return inputs

Input Latency

Local Input

Local input has no intentional latency:
input = collect_local_input()
result = world.step(dt, inputs=[input])

Network Input (Rollback)

Rollback netcode adds input delay for stability:
# Buffer local input
input_buffer.add(current_frame, local_input)

# Wait for remote inputs (with delay)
simulate_frame = current_frame - INPUT_DELAY

if has_all_inputs(simulate_frame):
    inputs = get_inputs_for_frame(simulate_frame)
    result = world.step(dt, inputs=inputs)

Input Replay

Replays store and playback inputs:
src/crimson/replay/codec.py
def decode_replay_inputs(
    replay: Replay,
) -> list[InputFrame]:
    """Decode all inputs from replay."""
    
    frames = []
    for encoded_frame in replay.input_frames:
        frame = decode_input_frame(encoded_frame)
        frames.append(frame)
    
    return frames

# Playback
for tick_index, input_frame in enumerate(replay_inputs):
    result = world.step(
        dt=FIXED_DT,
        inputs=input_frame.as_list(),
    )

Configuration

Input bindings are stored in crimson.cfg:
src/grim/config.py
class PlayerConfig(msgspec.Struct):
    input_type: InputType
    
    # Keyboard bindings
    key_up: int = rl.KEY_W
    key_down: int = rl.KEY_S
    key_left: int = rl.KEY_A
    key_right: int = rl.KEY_D
    key_reload: int = rl.KEY_R
    key_fire: int = rl.MOUSE_LEFT_BUTTON
    
    # Gamepad bindings
    gamepad_id: int = 0
    
    # Aim scheme
    aim_scheme: AimScheme = AimScheme.MOUSE

Next Steps

Gameplay System

How inputs affect gameplay

Replay Module

How inputs are recorded

Deterministic Pipeline

How inputs flow through simulation

Crimson Module

Game logic overview

Build docs developers (and LLMs) love