On this page
- Why Your Movement Code Shouldn’t Know What Key Was Pressed
- The Three Lives of a Button (And Why All Three Matter)
- Wiring Keys to Actions and Filtering OS Repeats
- The Frame Lifecycle: Three Steps in Strict Order
- TileMap Data Structures: Stacks and Reality Variants
- Camera-Based Frustum Culling
- Dynamic Tiles: Clear-and-Repopulate Every Frame
Why Your Movement Code Shouldn’t Know What Key Was Pressed
Coupling movement logic directly to SDLK_A or SDLK_LEFT is the kind of thing that seems fine until you try to add gamepad support, want rebindable keys, or need to write tests for movement behavior without simulating raw keyboard events.
The Multiversal Consciousness engine introduces an abstraction layer between hardware events and game logic. Two enumerations form the vocabulary of this abstraction. The InputAction enum encodes intent:
enum class InputAction : uint8_t {
MOVE_UP,
MOVE_DOWN,
MOVE_LEFT,
MOVE_RIGHT,
JUMP,
INTERACT,
SWITCH_REALITY,
PAUSE,
POSSESS_AGENT_1,
// ... through POSSESS_AGENT_9
};
The uint8_t backing type keeps the enum compact in memory — it’s used as a key in hash maps queried every frame, so size matters.
Your movement system will ask “is MOVE_LEFT active?” and never mention a keycode. That’s the entire point. To add gamepad support, you’d add controller bindings that map to the same InputAction values — your movement system doesn’t change.
The InputState enum encodes the temporal phase of a button press:
enum class InputState : uint8_t {
RELEASED,
PRESSED,
HELD
};
The Three Lives of a Button (And Why All Three Matter)
If you’ve built a game with only a boolean is_key_down check, you’ve probably hit one of these:
- Player taps jump and the character launches on every frame the key is held.
- A door toggle fires dozens of times per second because interact is checked continuously.
- A charge attack never works because there’s no way to distinguish “just pressed” from “still holding.”
The three-state model fixes all of these by encoding when the transition happened, not just whether the key is down:
| State | Duration | Typical Use |
|---|---|---|
RELEASED | Until next press | Idle; no action taken |
PRESSED | Exactly one frame | Jump initiation, toggles, one-shot events |
HELD | From second frame of holding until release | Continuous movement, charge mechanics, sprint |
The transition is deterministic:
RELEASED ---[key down]---> PRESSED ---[still down next frame]---> HELD
^ |
+---------------------[key up]----------------------------------+
This gives you three query methods with distinct semantics:
bool InputManager::is_action_active(InputAction action) const {
auto it = action_states_.find(action);
if (it != action_states_.end()) {
return it->second == InputState::PRESSED ||
it->second == InputState::HELD;
}
return false;
}
bool InputManager::is_action_just_pressed(InputAction action) const {
auto it = action_states_.find(action);
if (it != action_states_.end()) {
return it->second == InputState::PRESSED;
}
return false;
}
bool InputManager::is_action_just_released(InputAction action) const {
auto current_it = action_states_.find(action);
auto previous_it = previous_action_states_.find(action);
if (current_it != action_states_.end() &&
previous_it != previous_action_states_.end()) {
return current_it->second == InputState::RELEASED &&
(previous_it->second == InputState::PRESSED ||
previous_it->second == InputState::HELD);
}
return false;
}
is_action_just_pressed() returns true for exactly one frame. Use it for jumps, interaction triggers, one-shot events. Don’t call it inside a physics callback that runs multiple times per frame — the PRESSED state is frame-scoped, and the first check will consume it logically even if the flag persists until end of frame.
Wiring Keys to Actions and Filtering OS Repeats
The constructor calls initialize_default_mappings(), which wires physical keys to action intents:
void InputManager::initialize_default_mappings() {
key_mappings_[SDLK_A] = InputAction::MOVE_LEFT;
key_mappings_[SDLK_LEFT] = InputAction::MOVE_LEFT; // Both A and left arrow work
key_mappings_[SDLK_D] = InputAction::MOVE_RIGHT;
key_mappings_[SDLK_RIGHT] = InputAction::MOVE_RIGHT;
key_mappings_[SDLK_W] = InputAction::JUMP;
key_mappings_[SDLK_UP] = InputAction::JUMP;
key_mappings_[SDLK_SPACE] = InputAction::JUMP; // Three bindings for jump
key_mappings_[SDLK_E] = InputAction::INTERACT;
key_mappings_[SDLK_ESCAPE]= InputAction::PAUSE;
// ... number keys for agent possession
for (const auto& [key, action] : key_mappings_) {
action_states_[action] = InputState::RELEASED;
previous_action_states_[action] = InputState::RELEASED;
}
}
SDL delivers keyboard events through its event queue. The OS generates key-repeat events when a key is held down — helpful for text input, poison for game input. Holding right arrow should produce one PRESSED transition followed by continuous HELD frames, not a stream of spurious press events.
flowchart LR
A["SDL_Event<br/>(KEY_DOWN / KEY_UP)"] --> B["process_event()"]
B --> C{Key already in<br/>pressed_keys_?}
C -->|"KEY_DOWN & not pressed"| D["Insert into pressed_keys_<br/>Insert into newly_pressed_keys_"]
C -->|"KEY_DOWN & already pressed<br/>(repeat!)"| E["Ignore event"]
C -->|"KEY_UP & was pressed"| F["Remove from pressed_keys_<br/>Insert into newly_released_keys_"]
D --> G["update()"]
F --> G
G --> H["update_action_states()<br/>RELEASED→PRESSED→HELD"]
H --> I["trigger_callbacks()"]
I --> J["Query methods"]
G --> K["Clear newly_pressed_keys_<br/>Clear newly_released_keys_"]
The guard in process_event() — “only process KEY_DOWN if the key is not already in pressed_keys_” — is what kills OS repeats. The newly_pressed_keys_ set can contain at most one entry per physical key per frame, guaranteeing that RELEASED → PRESSED fires exactly once per press.
bool InputManager::process_event(const SDL_Event& event) {
switch (event.type) {
case SDL_EVENT_KEY_DOWN: {
SDL_Keycode key = event.key.key;
if (pressed_keys_.find(key) == pressed_keys_.end()) {
pressed_keys_.insert(key);
newly_pressed_keys_.insert(key);
}
break;
}
case SDL_EVENT_KEY_UP: {
SDL_Keycode key = event.key.key;
if (pressed_keys_.find(key) != pressed_keys_.end()) {
pressed_keys_.erase(key);
newly_released_keys_.insert(key);
}
break;
}
}
return true;
}
The KEY_UP guard also matters: it only processes release if the key was actually in pressed_keys_. This handles orphaned key-up events from window focus changes — if the window lost focus while a key was held, the key-up might arrive after refocus without a matching key-down in this session.
The Frame Lifecycle: Three Steps in Strict Order
update() must be called exactly once per frame, after all SDL events have been processed:
void InputManager::update(float delta_time) {
previous_action_states_ = action_states_; // Step 1: Snapshot previous frame
update_action_states(); // Step 2: Recompute from raw key data
trigger_callbacks(delta_time); // Step 3: Fire callbacks
newly_pressed_keys_.clear(); // Step 4: Clear frame-scoped sets
newly_released_keys_.clear();
}
update_action_states() is where the three-state transitions happen. For each action, it checks whether any mapped key is in pressed_keys_. If so, it looks at the previous state to determine whether to assign PRESSED (first frame) or HELD (subsequent frames):
void InputManager::update_action_states() {
for (const auto& [action, state] : action_states_) {
InputState new_state = InputState::RELEASED;
for (const auto& [key, mapped_action] : key_mappings_) {
if (mapped_action == action &&
pressed_keys_.find(key) != pressed_keys_.end()) {
InputState previous_state = previous_action_states_[action];
new_state = (previous_state == InputState::RELEASED)
? InputState::PRESSED
: InputState::HELD;
break;
}
}
action_states_[action] = new_state;
}
}
The callback system is an alternative to polling. Instead of checking is_action_just_pressed(JUMP) in your jump system, you register a callback that fires automatically when the action state changes. Multiple systems can listen to the same action. One important constraint: don’t modify the callback registrations from within a callback — you’d invalidate the iterator trigger_callbacks() is using. Defer structural changes to the next frame.
reset_states() handles scene transitions. Without it, a key held during a level transition would arrive in the next level as already HELD rather than fresh RELEASED.
TileMap Data Structures: Stacks and Reality Variants
The tile renderer supports multiple layers per cell and reality-specific visual variants — both needed for Multiversal Consciousness’s dual-reality puzzle mechanics.
Each tile carries default texture and color fields, plus optional reality A and reality B overrides:
struct Tile {
int texture_id = 0;
SDL_FRect source_rect{0.0f, 0.0f, 32.0f, 32.0f};
SDL_FColor color{1.0f, 1.0f, 1.0f, 1.0f};
int layer = 0;
bool visible = true;
int reality_a_texture_id = 0;
int reality_b_texture_id = 0;
SDL_FColor reality_a_color{1.0f, 1.0f, 1.0f, 1.0f};
SDL_FColor reality_b_color{1.0f, 1.0f, 1.0f, 1.0f};
};
The TileMap arranges tiles in a 2D grid where each cell is a vector — a stack of tiles at different layers:
struct TileMap {
std::vector<std::vector<std::vector<Tile>>> tiles; // [y][x][layer_stack]
int width = 0, height = 0, tile_size = 32;
};
set_tile() handles layer-aware insertion. If a tile exists at the same layer, replace it. Otherwise, append and re-sort by layer:
bool TileMap::set_tile(int x, int y, const Tile& tile) {
if (x < 0 || x >= width || y < 0 || y >= height) return false;
auto& stack = tiles[y][x];
for (auto& existing : stack) {
if (existing.layer == tile.layer) { existing = tile; return true; }
}
stack.push_back(tile);
std::sort(stack.begin(), stack.end(),
[](const Tile& a, const Tile& b) { return a.layer < b.layer; });
return true;
}
Tile stacks are almost always 1–3 elements deep. Sorting tiny arrays is nanoseconds. If you had deeper stacks, std::lower_bound for sorted insertion would be more efficient.
Camera-Based Frustum Culling
The render loop calculates which tiles are visible and skips everything else. The Camera struct transforms between world space and screen space:
void Camera::world_to_screen(float world_x, float world_y,
int& screen_x, int& screen_y) const {
screen_x = static_cast<int>((world_x - x) * zoom + viewport_width / 2.0f);
screen_y = static_cast<int>((world_y - y) * zoom + viewport_height / 2.0f);
}
void Camera::screen_to_world(int screen_x, int screen_y,
float& world_x, float& world_y) const {
world_x = (screen_x - viewport_width / 2.0f) / zoom + x;
world_y = (screen_y - viewport_height / 2.0f) / zoom + y;
}
The render loop converts screen corners to tile coordinates with padding, then iterates only the visible range:
int tile_left = std::max(0, static_cast<int>(world_left / tile_size_) - 1);
int tile_top = std::max(0, static_cast<int>(world_top / tile_size_) - 1);
int tile_right = std::min(tile_map_->width - 1, static_cast<int>(world_right / tile_size_) + 1);
int tile_bottom = std::min(tile_map_->height - 1, static_cast<int>(world_bottom / tile_size_) + 1);
The std::max(0, ...) and std::min(width-1, ...) clamps are not optional. Without them, camera movement beyond map edges causes out-of-bounds tile array access.
The outer loop iterates layers 0 through 10; the inner loops iterate only the visible tile range. Layer-first ordering ensures correct back-to-front drawing across the entire visible area.
Reality-aware rendering selects textures based on which reality is currently active. If reality_a_texture_id is zero, it falls back to the default texture_id. A rock that looks the same in both realities uses only the default fields. A bridge that exists only in Reality B has reality_b_texture_id set and reality_a_texture_id left at zero.
Dynamic Tiles: Clear-and-Repopulate Every Frame
Static tiles (terrain, background) are placed once at level load. Doors, water, switches, and trees are dynamic — they exist as ECS entities and can change state. The TileRenderer::update() method handles this with a clear-then-repopulate pattern every frame.
Phase 1 clears all tiles with dynamic texture IDs (5–8) from every cell. Phase 2 iterates each dynamic entity type and recreates tiles from current component data:
// Phase 1: Clear all dynamic tiles
for (int y = 0; y < tile_map_->height; ++y)
for (int x = 0; x < tile_map_->width; ++x)
tile_map_->remove_tiles_by_id_range(x, y, 5, 8);
// Phase 2: Repopulate from ECS state
// Example: doors (texture ID 6)
for (EntityID entity : door_container->get_entities()) {
const auto* door = component_registry_->get_component<Door>(entity);
const auto* transform = component_registry_->get_component<Transform>(entity);
Tile tile;
tile.texture_id = 6;
tile.layer = 2;
tile.color = door->is_open
? SDL_FColor{1.0f, 1.0f, 1.0f, 0.2f} // Open: transparent
: SDL_FColor{1.0f, 1.0f, 1.0f, 1.0f}; // Closed: opaque
tile_map_->set_tile(tile_x, tile_y, tile);
}
| Texture ID | Entity Type | Dynamic Visual Behavior |
|---|---|---|
| 5 | Tree | Disappears when entity is destroyed |
| 6 | Door | Alpha 0.2 when open, 1.0 when closed |
| 7 | Water | Ice-like blue when solid, semi-transparent when normal |
| 8 | Switch | Green tint when activated |
The clear-and-repopulate pattern trades incremental update complexity for simplicity and correctness. You don’t need to track deltas (“did this door open since last frame?”). For maps under a few thousand tiles with a few dozen dynamic entities, this runs well within one millisecond per frame.
If you add a new dynamic entity type, assign it a texture ID in the 5–8 range — or extend the range in the remove_tiles_by_id_range() call. If you use an ID outside this range, tiles will accumulate as ghost visuals across frames.
All textures live in TexturePtr, a std::unique_ptr<SDL_Texture, SDLDeleter> with a custom deleter that calls SDL_DestroyTexture(). Texture leaks are structurally impossible — the destructor handles cleanup even if loading fails midway through level initialization.