Quick answer: Call pygame.joystick.init() and then Joystick(i).init() on each connected device. Without per-device init, no JOYHATMOTION events flow.
Your menu navigation should respond to the d-pad. JOYBUTTONDOWN events for the face buttons arrive correctly. JOYHATMOTION events never appear. The d-pad on the same controller is dead.
Joystick Init Has Two Steps
Pygame requires explicit two-stage joystick initialization:
pygame.joystick.init()— enables the joystick subsystem.Joystick(i).init()— opens device i for reading.
Without step 2, the device shows up in pygame.joystick.get_count() but produces no events.
The Fix
import pygame
pygame.init()
pygame.joystick.init()
joysticks = []
for i in range(pygame.joystick.get_count()):
j = pygame.joystick.Joystick(i)
j.init()
joysticks.append(j)
print(f"opened {j.get_name()} with {j.get_numhats()} hat(s)")
The print verifies hats are present. If get_numhats() returns 0 for your controller, the d-pad is exposed as axes or buttons instead — check via SDL2 mapping or simply log all event types from your loop and identify which one fires on d-pad press.
Handle the Events
for event in pygame.event.get():
if event.type == pygame.JOYHATMOTION:
x, y = event.value
if y == 1: menu_up()
elif y == -1: menu_down()
if x == 1: menu_right()
elif x == -1: menu_left()
The value is a tuple (x, y) in {-1, 0, 1}. (0, 0) fires on release.
Hot-Plug Support
Pygame 2 supports controller hotplug via JOYDEVICEADDED / JOYDEVICEREMOVED events. Re-init when a controller connects mid-game:
if event.type == pygame.JOYDEVICEADDED:
j = pygame.joystick.Joystick(event.device_index)
j.init()
joysticks.append(j)
elif event.type == pygame.JOYDEVICEREMOVED:
joysticks = [j for j in joysticks if j.get_instance_id() != event.instance_id]
The newly added joystick gets initialized; removed ones are pruned from your list.
set_allowed Trap
If you use pygame.event.set_allowed([KEYDOWN, KEYUP]) to limit event types for queue performance, JOYHATMOTION is excluded by default. Add it to the allowed list:
pygame.event.set_allowed([
pygame.QUIT,
pygame.KEYDOWN, pygame.KEYUP,
pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP,
pygame.JOYHATMOTION, # don’t forget this
pygame.JOYDEVICEADDED, pygame.JOYDEVICEREMOVED,
])
Verifying
Press the d-pad and log event.type. JOYHATMOTION events should appear with appropriate (x, y) tuples. If you see no events at all, init step 2 is missing or the device exposes d-pad as axes (check JOYAXISMOTION on axis 6/7 or buttons 11–14, common for Xbox layouts via DirectInput).
“Two inits, one allow list. Skip either and your d-pad is invisible.”
Always log event.type for unknown events during early dev — saves hours when a new controller doesn’t behave like the last one.