From Quake Wiki
Splitscreen support is enabled by setting cl_splitclients to a value between 1-3 (the number of additional players to use). It can be changed mid-game, but not in single-player (coop will get forced to 1 at map start if deathmatch is not already set), so you may still need to restart the map for it to take effect. This same cvar is also used to control how many players to spectate at once when watching MVDs. Each player is typically referred to as a 'seat' (with the term 'client' referring to the shared collection of all seats). The server's logic makes little distinction, and the ssqc has little awareness of the feature.
The engine's input devices have a 'device id' associated with them. This assumes that the operating system actually provides support for individual devices instead of reporting them in aggregate. For instance, on windows you may NEED to `set in_rawinput 1;in_rawinput_keyboard 1;in_restart` before the engine receives events for individual mice. The device id will be set per-device on the first button/key click/press, and should be forgotten on the next in_restart command. This feature should enable you to more easily decide which input devices should affect which players - player1 should press their mouse+keyboard keys first, THEN player2, THEN player3, THEN player4.
If you have two players trying to play with gamepads, and a single shared keyboard for those awkward keys that your gamepad doesn't have enough buttons for, you can find individual keys to eg `+p2 forward` or `p3 impulse 7` to move the second player forward or switch the second player's weapon to the RL, etc. You should not bind the gamepad that way - all players share the same keybinds, so all gamepads would control p2... If you're debugging multiplayer mods with splitscreen and have only one keyboard, you can use the in_forceseat to quickly force all input devices to control a single player. This is obviously not useful for regular multiplayer games, just for mod development/testing.
Note that the additional seats are all multiplexed on a single network connection. This means that the following server extensions may NOT compatible (they will act only in the context of the first player): DP_ENT_EXTERIORMODELTOCLIENT, DP_SV_CUSTOMIZEENTITYFORCLIENT, DP_SV_DRAWONLYTOCLIENT, DP_SV_NODRAWTOCLIENT, viewmodelforclient, and CSQC's SendEntity field. There may be others. Much of these can be resolved by making the relevant decisions inside the csqc instead of the ssqc (note that MVD playback will require the same considerations). Extensions like DP_SV_DROPCLIENT will kick ALL associated clients, so watch out for that.
Stuffcmds may be problematic - each client has its own cbuf to try to handle stuffcmds, so eg team/colour stuffcmds will affect the correct player, but there are still various commands+cvars that affect the engine as a whole, as opposed to per-seat.
A basic tenant of CSQC is that it owns the entire screen, thus for splitscreen to work with it, it MUST be aware of the splitscreen feature, just as it should be aware of MVDs if it wishes to display multiple perspectives simultaneously. To select which seat the various builtins refer to (like getstat*), the csqc should set VF_ACTIVESEAT to a value between 0 and numclientseats-1. To efficiently draw multiple seats, the csqc should change VF_ACTIVESEAT to find the player's entity number, and then change VF_VIEWENTITY to match. This action will rewrite all entities that were already added to the scene - any viewmodel entities will be stripped while EXTERNALMODEL flags will be cleared and then set on your new viewentity. This saves having to run through addentities and all the predraw functions, but can have issues if you're generating viewspace-oriented polygons. Note that events like eg CSQC_ParseDamage will be called in the context of the player that was hurt. This means you may need to query VF_ACTIVESEAT and use arrays for your globals in order to ensure that the different player states do not end up fighting with each ohter.