Several people playing on one Linux PC at the same time. Each in their own session with their own Steam account, their own controller and their own screen, streamed to their own Moonlight client. The machine's regular desktop keeps running undisturbed while they play.
A seat is a container with its own desktop, its own Steam account and its own Sunshine, streamed to one Moonlight client. One graphics card serves all of them. Polyseat implements neither a compositor, nor an encoder, nor a streaming protocol: Incus, Sway, Sunshine and PipeWire do that work, and Polyseat is the orchestrator on top. It builds the seats, wires them up collision-free, sends each person's input to the right one, shares the game library, and repairs what drifts.
- Arch, or something based on it. The installer speaks
pacmanrather than pretending to be portable. - An NVIDIA or AMD card with a working driver. NVIDIA also needs
lib32-nvidia-utils, or 32 bit games will not find the GPU. AMD needs onlyamdgpuon the host. The installer refuses rather than warns if the driver does not answer, because a seat without it comes up, streams in software and looks perfectly healthy. The AMD path has never been run on real hardware: docs/amd.md says what was verified and what was not. - A wired network connection. Each seat is a host of its own on the LAN, and Wi-Fi cannot do that: it carries one MAC address per connection.
- btrfs, or XFS with
reflink=1— but only for the shared game library. On ext4 the seats still work, the sharing simply stays off and every seat downloads its own games.
The packages Polyseat itself needs, the installer works out and installs.
1. Install it, once per machine.
curl -LO https://github.com/superuser404notfound/Polyseat/releases/latest/download/polyseat-x86_64.pkg.tar.zst
sudo pacman -U polyseat-x86_64.pkg.tar.zst
sudo systemctl enable --now polyseatd
Downloaded first and installed second, because pacman -U on a URL wants a
signature that these packages do not carry yet. The link has no version in it
and never will: it is always the newest release, and pacman -Qi polyseat says
which one arrived.
That is the whole of the command line. The machine itself is not ready yet, and that part happens in the page: a package is not allowed to do it, and the daemon is. Preparing it and removing Polyseat again are both buttons.
Not in the AUR, and anything that turns up there under this name is not this. Who does what, and why it is split this way: docs/installation.md. Building a particular commit from a checkout instead: CONTRIBUTING.md.
2. Open https://<this machine>:47800 and choose a password. Nobody has
claimed the machine yet, so the page asks you to set one rather than to type
one. Do it before anybody else does: until it is set, whoever reaches the page
can set it. The browser will ask about the certificate once, exactly as
Sunshine's own interface makes it ask.
The interface answers on the whole network, so seats can be managed from the
same phone that runs Moonlight. To keep it on this machine only, set listen
to 127.0.0.1:47800 in /etc/polyseat/polyseatd.json.
3. Press Prepare this host. It is the first thing on the page, above
the seats, until this machine has one. The button installs the missing packages,
writes the idmap range every container start needs, brings Incus up and
initialises it, checks that the graphics driver answers, and puts your account
in the input group. It reports each step as it happens, and every step checks
before it changes anything, so pressing it again on a machine that is already
ready changes nothing — which is also how to check that it is.
Afterwards it moves under Host, at the top of the page, along with removing Polyseat and asking GitHub for a newer version.
sudo polyseat-prepare is the same thing from a terminal, down to the same
file, if you would rather watch it there. Either way, Polyseat restarts into its
own interface a few seconds after Incus comes up, and the page follows it.
4. Add a seat and press provision. The daemon does the rest: image, packages, driver, Steam, desktop, session. It takes a few minutes and the card shows each step as it happens.
5. Pair a device. Open Devices and pairing on the seat's card, point Moonlight at the address shown at the top of that card, and type the PIN Moonlight gives you into that field. The same panel lists what is already paired and can unpair it. Every seat is paired here, rather than in a Sunshine page of its own.
That is all. Repeat 4 and 5 for the second person.
The interface says when a newer version is out and offers two buttons: one installs it, one restarts the daemon. Installing is safe in the middle of somebody's game, because replacing the binary leaves the running process alone. The restart is what makes the new version take effect, and it is refused while somebody is streaming and says whose game it would have ended.
By hand it is the same two steps:
curl -LO https://github.com/superuser404notfound/Polyseat/releases/latest/download/polyseat-x86_64.pkg.tar.zst
sudo pacman -U polyseat-x86_64.pkg.tar.zst
sudo systemctl restart polyseatd
Seats are not touched by an update. If a new version builds them differently, the interface names the ones that are behind and offers one button to bring them up to date, at a moment you pick rather than in the middle of somebody's game.
Three things in the interface reach root, and this is one: installing a release,
preparing the machine, and removing Polyseat. "web_update": false in
/etc/polyseat/polyseatd.json turns off the first two, "web_uninstall": false
turns off the third, and "update_needs_password": true makes the first two ask
for the interface password when they are pressed. Removing asks for it either
way.
What the update never does is let the browser say what to install: it takes the release the daemon found itself, from this project's own downloads, and checks it against the checksum that release states.
The check for a new version is one request to GitHub every six hours, it sends
nothing about the machine, and it installs nothing on its own. Host has a
button that asks now rather than waiting for the next one, and says when the
daemon last managed to ask, because "nothing newer" and "nothing heard" are
different answers. "update_check": false turns all of it off. CHANGELOG.md is where
the differences between versions are written down, and the version actually
serving is at the bottom of the interface.
Host in the interface, then Remove Polyseat. It asks for the password again, because it is the one button here that pressing a second time does not undo. At a terminal it is the same file:
sudo polyseat-uninstall
Your seats stay, unless the box that says otherwise is ticked. This takes out
the daemon, its unit, the udev rule, the helpers and the package, and touches
neither the containers nor /var/lib/polyseat. Install it again and the seats
come back as they were.
To take the seats with it, tick Delete the seats as well, or
sudo polyseat-uninstall --seats. The shared game library is kept even then,
because the seats' copies come back from it in a second by sharing blocks where
downloading them again does not; --library, or the second box, removes that
too.
The daemon is stopped before anything it owns is touched, and that order is the reason this is a command rather than a paragraph: deleting a container while the daemon is still supervising it is what leaves Incus with a stop that never finishes.
Incus, bpftrace and python stay where they are. They are not Polyseat's to
remove, which is also why the package goes with pacman -R rather than -Rns:
on a machine where pacman pulled Incus in as a dependency, the s would take
somebody's container manager away.
The interface stops answering a moment in, because the daemon serving it is the
first thing to stop. That is what it looks like when it worked, and the rest of
the run is in the journal: journalctl -u polyseat-uninstall.
Seats are built in one click and play in parallel. polyseatd takes a seat
from nothing to a running session: container, network, driver, Steam, desktop,
input. It also brings seats that were built by an older version forward, with
one button that updates every one of them.
Input goes exactly where it belongs. Each client's keyboard, mouse and gamepad reach that client's session and nothing else. No crossover between seats, and the host desktop never sees any of them: a controller plugged in for seat 2 does not steer the host's Steam. Which seat a device belongs to comes from what the kernel says created it, not from the name the device claims.
A game installed once is playable in every seat, without being downloaded again, and that includes the games that were already on the machine. Nothing has to be chosen for it: the shared library is the only library a seat's Steam has, so signing in and pressing install is enough. Each seat keeps its own fully writable copy, and the copies share their blocks on disk, so taking this machine's 69 GB library into the pool took 0.8 seconds and 432 KB. It keeps working after a game updates, and a seat that is behind is brought forward as soon as nothing in it is using the files.
Launchers other than Steam work too. Each seat has a shared/ directory
where one folder is one game; point Heroic, Lutris or Bottles at it and the game
appears in the other seats by itself.
Every seat carries Proton CachyOS alongside Valve's own, set as the default, keeping itself up to date, and waiting for a seat that is neither streaming nor using the files before it replaces anything.
A seat can share the network with the host, or stay behind a line. Local
multiplayer between the host and a seat needs the first, which is a button in
the interface under "The uplink", or sudo polyseat-lan-bridge at a terminal;
whether a particular seat takes part is a checkbox on its card. Turned off, the seat reaches the gateway and the other
seats, but not this machine, and this machine not it.
A seat is something you can sit down in front of. Connecting lands on a
desktop with a launcher, a bar and a file manager, not on a bare terminal.
Moonlight's app list is generated from what the seat really has, with box art,
so Steam Big Picture and the installed games are one pick away before a stream
even starts. Software goes in from either end, with no password and no root: the
player types flatpak install ... in the seat, or somebody installs it into
that seat from the web interface and watches the progress bar. AppImages count
too, which matters because many emulators are published that way and no other:
paste the address into the web interface, or drop the file in ~/Downloads
inside the seat, and it appears in both menus by itself.
Every client gets the picture it asked for. The seat's screen is virtual, so it simply becomes the size and refresh rate the client wants. The framerate is capped from outside rather than by turning vsync on, so games stay uncapped and pay no vsync latency, and one setting covers native games, Proton, flatpaks and emulators alike. Measured in a seat: 14866 fps uncapped becomes 60.00 fps with a 60 Hz client, at 0.03 ms of frametime jitter against 0.40 ms for vsync alone.
A controller is enough. Streaming from an Apple TV or a phone means no keyboard and no mouse, so the seat carries both: an on-screen keyboard, and a pointer driven by the gamepad, left stick to move and right stick to scroll. It turns itself on when the desktop is in front and hands the controller back to a fullscreen game; holding two of Select, Start and Guide for a second overrides that by hand, and the pad buzzes to say it took.
Everything above was confirmed on real hardware, on one machine: an Arch host
with an RTX 4080, most recently on 2026-07-31. The logs of each step live in
spike/. Whether any of it holds on a machine that is not that one is
the open question this project would most like answered, and
docs/amd.md is where it is most open.
Who is on which seat is on the seat's own card while somebody is streaming: what they picked, at what size and framerate, from which address, and since when.
Each seat card carries its own log. Two lines in it are worth watching. The input broker says, for every device, whether its owner was really established or only claimed:
+ event29 Keyboard passthrough (seat2)
creator verified: ID_INPUT=1 ID_INPUT_KEY=1 ID_INPUT_KEYBOARD=1
! event260 refused: name claims (seat1) but the kernel says 'seat2' created it
The other is the encoder. A seat that ended up encoding on the CPU still starts,
still streams and still looks healthy, so the card shows nvenc or vaapi when
it is right and says so plainly when it is not.
For the host itself:
sudo polyseatd -report # everything about this installation, in one go
polyseat-check-hardening # console and device exposures
journalctl -fu polyseatd
polyseatd -report is what to put in a bug report. Version, distribution,
kernel, card and driver, Incus, the filesystem, the network, every seat, and the
last 200 journal lines. It runs without the daemon, which is the point: it is
wanted most on a machine where the daemon will not start. It reads and changes
nothing and opens no password or key, but it does carry this machine's host
name, its seat names and their private addresses, and says so at the top. Read
it before pasting it somewhere public.
It does not share licences: a seat can only play what its own account owns. It is built for trusted local users on one machine, not for handing a seat to a stranger over the internet. And it does not hide a missing reflink filesystem behind full copies: the daemon says so plainly instead.
Architecture and the reasoning behind every decision:
docs/architecture.md. What the isolation actually
guarantees, measured rather than assumed:
docs/security.md. Who installs what:
docs/installation.md. What changed between versions:
CHANGELOG.md.
Running it on hardware that is not the author's is the most useful thing
anybody can do for this project, and reporting back either way is the point.
How, and what the code and tests here look like:
CONTRIBUTING.md. Reporting a security problem privately,
and what is already known and deliberately accepted:
SECURITY.md.
| M0 | Input spike: does the container architecture hold up? | ✅ |
| M1 | One seat: Sway + Sunshine + NVENC + Moonlight | ✅ |
| M2 | Input broker: keyboard, mouse and pad reach the seat | ✅ |
| M3 | A seat that actually plays: Steam, Proton, pad, audio | ✅ |
| M4 | Two seats in parallel, input strictly separated | ✅ |
| M5 | Daemon + GUI: create, start, pair and monitor seats | ✅ |
| M6 | Shared game library: install once, play in every seat | ✅ |
| M7 | A usable seat: desktop, app list, software, resolution and framerate per client | ✅ |
GPL-3.0-or-later. See LICENSE.