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:

  1. pygame.joystick.init() — enables the joystick subsystem.
  2. 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.