The problem in my living room was arithmetic: one TV, one Linux box, and more than one local Minecraft player. My son, my wife, and I had been playing it together on the Xbox 360; the PC version was better, and I wanted to keep the controllers. My first idea was porting a Windows-only controller library to Linux, and I wrote to its author in Switzerland for the source before noticing the dependency that made it Windows-only. Consoles solved this decades ago with split screen. Minecraft on the PC had no such mode—one window, one mouse pointer, one keyboard focus. X11 had machinery to fix that at the display-server level: XInput2’s device hierarchy allows multiple independent pointer/keyboard pairs on one server.
So I wrote a window manager. The repo is xconsole. Five weeks, written in Python 2 against xpyb: couch co-op implemented through the display server.
The stack, bottom to top
At the bottom: xboxdrv, which turns each Xbox controller into synthetic keyboard and mouse devices. A systemd unit ran four controller slots, each with its own LED pattern and device name (xconsole:0 through xconsole:3), all sharing one config. The config file is a Minecraft control scheme transliterated into evdev:
[ui-axismap]
x1=KEY_A:KEY_D
y1=KEY_W:KEY_S
x2^dead:10000=REL_X:750:-1
y2^dead:10000=REL_Y:750:-1
[ui-buttonmap]
a=KEY_SPACE
tl^toggle=KEY_LEFTSHIFT
lt=BTN_RIGHT
rt=BTN_LEFTLeft stick is WASD, right stick is the mouse (with a dead zone), triggers are the mouse buttons, A is jump, clicking the left stick toggles sneak. The d-pad and bumpers scroll the hotbar via REL_WHEEL. The file even preserves a debugging comment from the era: #BUG: BTN_* wants a KEY_* to follow else outside pipeline???
Above that: the window manager itself, built on raw XCB through the xpyb binding—no toolkit, no library WM framework, wire-level X11 from Python.
One seat per gamepad
When a controller first produces input, the WM lazily creates a new logical device pair for it—a real, first-class X11 pointer and keyboard, exactly as if a second physical seat had been plugged into the server—then attaches the pad’s synthetic devices to that pair:
self.manager.conn.xinput.XIChangeHierarchyChecked([(
xinput.HierarchyChangeType.AddMaster,
1, # send_core
1, # enable
self.atom.NAME,
)]).check()AddMaster is the relevant XInput2 operation: after this call the X server has two (or three, or four) independent cursors, each with its own focus. Each seat then gets a “Port”—a fixed region of the screen; the working layout split the TV in half—and the WM does real reparenting-WM work in miniature: intercept ConfigureRequest, override the client’s requested geometry with the Port’s, reparent the Minecraft window into a frame, zero the border.
Two cursors on one screen create an immediate boundary problem: one pointer wandering into another player’s half. XFixes 5.0 has a per-device answer—pointer barriers restricted to a specific player’s pointer:
self.manager.conn.xfixes.CreatePointerBarrierChecked(
bid, self.window,
x, y, xx, yy, dirs,
1, [self.controller.mptr.deviceid],
).check()Four barrier segments per Port—top, right, bottom, left—fence each player’s cursor into their own half. The other player’s pointer passes through freely, because the barrier names exactly one device id. This is the per-device part of the design: the same boundary constrains one player’s pointer without constraining the others.
Focus had its own quirk: rather than fight SetInputFocus semantics, the WM hand-packs a 32-byte FocusIn event and sends it directly—struct.pack('BB2xIB23x', 9, 0, self.atom.WID[0], 0), type 9 being FocusIn, with the padding bytes counted out against the raw xEvent struct.
Below the binding
xpyb—the Python XCB binding—did not fully support XInput2. Its generated code was missing or broken for exactly the requests this project needed. The repo’s answer is a section headed #FIXME: workarounds to incomplete library generation: hand-marshalled X11 requests, packed byte by byte from the protocol spec and monkeypatched onto the binding. XISelectEvents is opcode 46, built with struct.pack('=xx2xIH2x', ...) into a buffer; XIChangeHierarchy is opcode 43; XIChangeProperty is opcode 57. Incoming generic events got the same treatment—a lookup table of struct format strings keyed by extension and event type, so events xpyb couldn’t decode still became usable objects:
__xge__ = mapo.automap()
__xge__[131][13]['xx2x4x2xHIIHHI4x'].update(enumerate((
'deviceid', 'time', 'detail', 'sourceid', 'valuators_len', 'flags',
)))mapo.automap() supplied the nested dictionaries holding those field names.
At the top of the stack, each seat launches its own Minecraft (Forge) client: a long classpath assembled as one NUL-joined string and split back into an argv—xargs -0 logic applied to Popen—plus a hand-tuned low-pause CMS garbage-collector configuration (-XX:MaxGCPauseMillis=10).
My commit message when the event loop worked was “event driven… It Works!” I never took xconsole beyond the living room it was built for. Each player could hold a controller, look at a separate half of the TV, and play at the same time.