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
Raw Input Collection
Collect keyboard/mouse/gamepad state from raylib.
Input Mapping
Map raw inputs to player actions (move, fire, reload).
Input Frame Build
Package inputs into PlayerInput structs.
Input Frame Normalization
Normalize for fixed player count (multiplayer).
Simulation
Pass normalized inputs to WorldState.step().
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
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)
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.
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)
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
Local input has no intentional latency:
input = collect_local_input()
result = world.step(dt, inputs = [ input ])
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)
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:
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