Quick answer: When the user alt-tabs away with a modifier held, the OS does not deliver the KEYUP to your background window. Pygame’s internal modifier bitmask stays set, so pygame.key.get_mods() reports Ctrl or Shift as held even after the user released them. Handle WINDOWFOCUSLOST and call pygame.key.set_mods(KMOD_NONE), or prefer pygame.key.get_pressed() which reads live OS state.
Your Pygame game has a Ctrl+click mechanic: Ctrl+click selects multiple units. Alt+Tab to check a guide, switch back, single-click a unit. It gets added to a selection as if Ctrl were still held. Press and release Ctrl once, and the bug disappears. This is a focus-related state desync that shows up in every Pygame project sooner or later.
The Symptom
After alt-tabbing with a modifier held, pygame.key.get_mods() returns a bitmask with KMOD_CTRL, KMOD_SHIFT, or KMOD_ALT set even though the user is not pressing anything. event.mod on subsequent key events is also stale. The user has to tap the modifier key once to “unstick” it.
Variants: only right-ctrl is stuck, or only on Windows, or only when alt-tabbing via Alt held down (not Windows key). CapsLock can also appear inverted because pygame tracked the CapsLock toggle but missed the key-up.
What Causes This
1. OS does not send KEYUP to unfocused windows. This is intentional OS behaviour: when you alt-tab, the key events go to the new foreground window. Your game sees a KEYDOWN for Alt, then nothing, then a WINDOWFOCUSLOST. The KEYUP for Alt is delivered to the Explorer task-switcher, not your game.
2. Pygame accumulates modifier state from events. pygame.key.get_mods() returns a bitmask that Pygame updates whenever it dispatches a KEYDOWN or KEYUP. Missing the KEYUP leaves the bit set.
3. event.mod snapshots the stale bitmask. When the user presses a new key after focus returns, Pygame builds the event and attaches event.mod from its current (stale) bitmask. So the event looks like Ctrl+click even though only click happened.
4. Sticky CapsLock and NumLock. These are toggle-style modifiers. If focus loss happens mid-toggle, pygame’s stored state may disagree with the OS forever until the user toggles the key again.
5. Platform differences. SDL2 (under pygame) emits synthetic KEYUP events on focus loss on some platforms but not others. Linux with X11 generally does; Wayland and Windows are inconsistent.
The Fix
Step 1: Clear modifier state on focus loss.
import pygame
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
def clear_modifier_state():
# Tell pygame no modifiers are held
pygame.key.set_mods(pygame.KMOD_NONE)
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.WINDOWFOCUSLOST:
clear_modifier_state()
elif event.type == pygame.WINDOWFOCUSGAINED:
# Resync from the OS in case the user is still holding something
clear_modifier_state()
# Next real KEYDOWN will set them correctly
_update_game()
pygame.display.flip()
clock.tick(60)
Step 2: Prefer get_pressed() for critical checks. pygame.key.get_pressed() queries the OS every call, so it does not carry stale state from missed events.
def is_ctrl_held():
# get_pressed() reads SDL's live key state — robust to focus changes
keys = pygame.key.get_pressed()
return keys[pygame.K_LCTRL] or keys[pygame.K_RCTRL]
def is_shift_held():
keys = pygame.key.get_pressed()
return keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT]
def on_mouse_click(pos):
if is_ctrl_held():
selection.add_at(pos)
else:
selection.replace_at(pos)
Step 3: Combine event.mod with get_pressed() on key events. If you need to know “was Ctrl held at the moment this key was pressed,” trust event.mod for recently dispatched events and fall back to get_pressed() for the current frame.
for event in pygame.event.get():
if event.type == pygame.KEYDOWN:
# Prefer live OS state to avoid stale event.mod
ctrl = is_ctrl_held()
shift = is_shift_held()
if event.key == pygame.K_s and ctrl:
save_game()
elif event.key == pygame.K_z and ctrl and shift:
redo()
elif event.key == pygame.K_z and ctrl:
undo()
Step 4: Force-clear on mouse-motion-without-button after focus regain. As a safety net: when focus returns, reset all modifier state and ignore the first click if it was potentially a click that started in another window.
just_regained_focus = False
for event in pygame.event.get():
if event.type == pygame.WINDOWFOCUSGAINED:
just_regained_focus = True
clear_modifier_state()
elif event.type == pygame.MOUSEBUTTONDOWN:
if just_regained_focus:
just_regained_focus = False
# Skip the first click if it might be a stray alt-tab click
continue
_handle_click(event.pos)
Step 5: On Linux with X11, enable repeat handling explicitly. X11 can also generate spurious KEYDOWN events without KEYUP for auto-repeat; pygame.key.set_repeat(0) disables that and avoids phantom modifier state.
Why This Works
Pygame (via SDL2) maintains two parallel concepts of modifier state. The first is the event-driven bitmask returned by pygame.key.get_mods(), updated incrementally from KEYDOWN/KEYUP events. The second is the live keyboard scan returned by pygame.key.get_pressed(), which queries SDL’s backend which queries the OS.
The event-driven bitmask is fragile because it trusts that every KEYDOWN will be matched by a KEYUP. That assumption breaks on focus loss, spurious OS behavior during alt-tab, or sandboxed environments that filter events. The live scan is expensive per call but is never stale, because it bypasses the event queue entirely.
Clearing modifiers on WINDOWFOCUSLOST is a belt-and-braces fix: it does not hurt when SDL already handled the release and it does fix the case when SDL did not. pygame.key.set_mods(KMOD_NONE) resets the event-driven bitmask so event.mod on the next event reflects reality.
"Modifier state is two sources of truth. If they disagree, get_pressed() is the one to believe."
Related Issues
For general Pygame focus handling, see Fix: Pygame Game Pauses on Focus Loss. If key repeat is firing unexpectedly, see Fix: Pygame Key Repeat Not Working.
KEYUP does not cross focus boundaries. Clear mods on FOCUSLOST and prefer get_pressed().