Skip to content

Players and Multi Controller

magmacrunchmedia edited this page Aug 24, 2026 · 1 revision

Players and Multi-Controller

Added in 0.5.0.

PlayerManager turns input sources into player seats: join-by-press, disconnect handling, and reconnection that never reshuffles who is P1 and who is P2.

from texastoast import HubPoller, KeyboardInput, PlayerManager

manager = PlayerManager(
    max_players=2,
    join_buttons=("a", "start"),
    on_join=lambda p: banner(f"Player {p.index + 1} joined!"),
    on_leave=lambda p: banner(f"Player {p.index + 1} disconnected"),
)
manager.add_source(keyboard)      # the keyboard is a claimable seat too

for hub in hubs:
    poller = HubPoller(hub).start()
    game.on_close(poller.stop)
    manager.add_hub(poller)       # one seat candidate per controller

def update(dt):
    manager.update()              # join scan + hotplug watch, once per frame
    for player in manager.joined_players:
        state = player.poll()     # a Player IS an InputSource
        move(player.index, state.dx, state.dy, dt)

Frame-driven, like the UI widgets: call manager.update() once per frame, then poll the seats you care about.

Joining

Joining is edge-triggered: a seat is claimed by a fresh press of a join button on an unassigned source. Holding A through the join screen claims one seat, not one per frame. on_join(player) fires synchronously from update(), on the game thread — no marshaling.

add_hub accepts a MagmaHub or a HubPoller (both expose num_controllers) and registers a MagmaHubInput per controller. Seats can be dropped manually with manager.release(player), which returns the source to the claimable pool.

Hotplug

Connection state rides the duck-typed connected attribute — MagmaHubInput forwards its hub's; KeyboardInput has none and therefore never leaves.

When a controller vanishes (loose wire, unplugged hub):

  1. The seat goes inactive and on_leave(player) fires.
  2. The seat's poll() returns idle — never the buttons that were held at the moment of disconnect. A stuck-walking player is the failure this exists to prevent.
  3. The seat keeps its source. When the controller answers again, the same seat reactivates and on_join(player) fires again — a bounced cable is the same physical controller, and reshuffling P1/P2 mid-game is exactly what a living-room console must not do. (There is deliberately no separate on_rejoin.)

player.joined (has a source) and player.active (source currently connected) are distinct for exactly this window.

Testing hotplug with the simulator

The whole flow runs against SimBus, no hardware needed — this is how tests/test_players.py covers it:

from texastoast import PlayerManager, simulated_hub
from texastoast.i2c.protocol import BTN_A

hub, sim = simulated_hub()
manager = PlayerManager()
manager.add_hub(hub)

sim.press(BTN_A); hub.poll(); manager.update()   # joins seat 0

sim.disconnect_hub(0x08); hub.poll(); manager.update()   # leave, idle poll
sim.reconnect_hub(0x08);  hub.poll(); manager.update()   # same seat, rejoined

examples/two_player_demo.py plays this out live: keyboard P1, a simulated autopilot P2 that joins, walks, gets "unplugged" mid-stride, and comes back — to the same seat.

Clone this wiki locally