New thoughts. I think the impactful participants system DOES make sense and has value. But let's start somewhere else and build up to the why. First some thoughts that tie in to each other: input filtering is indeed important. We should really give a dev an example like: getInputForThisFrame(localGameState) { if(!localGameState.players.contains(myId)) { if(localGameState.players.length < max_players && join_button_pressed) { return 'joinGame'; } return null; } return heldKeyDirection; // up, down, left, right... } This kind of filter is nice. If this node's player is NOT in the game and there's still place for more players, it will be able to send a 'joinGame' input. If this node's player DOES believe it's in the game, it can send inputs. There's NO GUARENTEE for any of this coming true on the other nodes, but, if the own node already doesn't believe his inputs have an impact, it's better to just filter them beforehand. Now, let's assume there's 1000 nodes in this game. There's 2 active players, and max_players is 4. So there's one more available spot for a player in this game! 20 players quickly send their 'joinGame' request at the same time! Obviously, not all 20 players can actually join, only 2 lucky ones can. The nodes in the network will receive these 20 inputs {tick: N, input: 'joinGame'} in different orders. All nodes store all of these inputs as they are coming in. The game DOES poll for 'joinGame' messages at tick number N. So that general input query SHOULD return... all 20 player id's? But the game can only accept one player. We should allow the predicate to specify to limit to only 2 player ids. So, the predicate should check all players inputs, find 20 players that meet the query (input == 'joinGame'), the engine orders those player ids based on node ID, and returns the first two results. The nodes with the lowest 2 ids win and get to join the game. Of course, as those 20 'joinGame' inputs come in at different delays, the node has to rollback, because the result for frame N's query changed. Example: originally, the query returned [5, 8] (player id's 5 and 8). But later you receive the 'joinGame' from player id 3. The returned result at frame N SHOULD have been [3, 5]. No problem, just roll back to that frame, and now you'll get the proper result. This all goes relatively well, and in the end, although there's now 22 nodes with inputs (the originally present 2 players + the 20 that tried to join), only 4 of them are now in the game. That means that 18 inputs are present but never used. The bad part is that although these 18 players don't influence the game at all, their inputs are still present, and they are now considered 'participants', and their inputs stay in memory (and are passed every state transfer) forever, until those participants 'leave' (disconnect or manual leave). Can we do better? Yes, we can! Let's first create this rule: {tick: N, input: null} (so a null input) means NO input. We're going to make an exception in input querying now. Players with a NULL input CANNOT ever be returned in a general query. I think this is fair, because: A spectator also has "no inputs", but we don't want to suddenly return all spectators in a query. So, any query that would resolve to true on a NULL input for a player (and thus return that player's id), is INVALID. Therefore, if a player's input is NULL, it will NEVER be returned as a player id in the general query. (Warning: in a player SPECIFIC query, nothing changes, and NULL is a valid result) Now, okay, that's fair, but how is this useful? Well, let's look at the original code again: getInputForThisFrame(localGameState) { if(!localGameState.players.contains(myId)) { if(localGameState.players.length < max_players && join_button_pressed) { return 'joinGame'; } return null; } return heldKeyDirection; // up, down, left, right... } let's say at frame N, a node sends 'joinGame', because it's not in the game. The input arrives at itself first (no network delay). So it will believe it won the race for a spot on in the game. So on tick N+1, it believes it is in the game. The next ticks it sends its heldKeyDirection. At some frame, let's say N+5, through a rollback, it turns out this local player's id in fact did NOT make it into the game. Therefore, what will the returned value now be from this function? That's right, null! The player is not in the game, but max_players is reached (also, the join button is probably not being pressed anymore, but that's irrelevant). The change from heldKeyDirection to null WILL be sent over the network, and after that, the node will not send more inputs really, because the game is happening without him in it. Now, this means that his final inputs will look something like this: {tick: N, input: 'joinGame'}, {tick: N+1, input: 'left'}, {tick: N+3, input: 'right'}, {tick: N+5, input: null} (sent 'joinGame' at frame N, held left button frame N+1 and N+2, changed held direction to right for frame N+3 & N+4, then changed input to NULL on N+5 and stayed that way ever since) Normally, as these inputs all finalize, we would have to keep {tick: N+5, input: null} in storage forever, because we always keep the last finalized input for all players. The input might get queried, after all. But that's exactly the key: "The input MIGHT get queried". Combine this with our NULL input restriction we just made. A player's input can ONLY be queried with their id. A player's id can ONLY be learned with a general query. A general query ONLY can return the player id for non-NULL inputs. In other words. IF the player's id was never returned in a general query yet, then as soon as the NULL is the most recent finalized input, that player's input can NEVER BE QUERIED until a new input arrives from that player. That means, we can safely delete this player's input array and turn them into a spectator! This is how we clean our memory, AND reduce bandwidth, because now we don't need to include it in state transfers anymore. It does mean, that the game engine has to keep track of which player ids it has returned to the game in general queries. The collection of these ids ARE the 'impactful participants'. They are participants, for which the id is known by the game, and their inputs can be queried at any time. Any participants NOT in this collection of ids are NOT 'impactful participants'. Their inputs do NOT influence the game. CASE: No 'in game based' input filtering, and why impactful nodes is great design. Remember the filtering code example? Imagine the dev did a bad job instead and wrote this: getInputForThisFrame(localGameState) { if(!localGameState.players.contains(myId)) { if(localGameState.players.length < max_players && join_button_pressed) { return 'joinGame'; } // THERE IS NO "return null;" HERE! } return heldKeyDirection; // up, down, left, right... } In that case, all 100 nodes in the simulation would constantly be sending their heldKeyDirection, whether they are actually in the game or not! Now, this is where our 'impactful participants' can make a difference. Not necessarily in the messages being sent over the network. Unfortunately this dev's sloppy implementation results in all 100 nodes' inputs being sent to each other, relayed through each other, included in state transfers... But, where 'impactful participants' CAN make a difference is in execution speed. wait scrap that. How 'impactful participants' can make a difference, is by setting a max_impactful_participants limit. For this game, this could be set at 4, because there can only be 4 impactful players at a time. Okay okay okay scratch all that. Here's where I'm tripping over myself. I'm currently trying to push two entirely different classes of inputs into one. 'joinGame' and 'left'/'right'/… are two DIFFERNT classes of input: - 'joinGame' is intended to be a kind of input that can turn a spectator into a participant - 'left'/'right'/'up'/'down' sort of inputs are NORMAL inputs Until now I've been treating them as one and the same. The 'general query' is a system that allows the dev to ask 'give me all player ids where input=joinGame' (just an example). This system in itself is also serving two different purposes: 1) spectator to participant promotion => 'joinGame' 2) searching within participants who did a certain action #2 can actually be scrapped probably. Just use player ids directly with queries for the same result. But the main problem is that I've been treating 'left', 'right', etc as inputs that can ALSO promote a spectator to a participant, and been introducing the NULL patch as a way around that, because that CANNOT promote a spectator to a participant. I need to more clearly divide these purposes. In this situation, 'joinGame' is a special input that is meant to turn a spectator into a participant 'left', 'right', etc are NORMAL inputs that CANNOT turn a spectator into a participant General queries as a concept should be SCRAPPED. In its place comes should come, what I have intended until now: A system/signal that promotes a node from spectator to participant. Okay I think I'm going in circles but I'm also getting more clarity slowly. New insights: There are: - Normal inputs: can be queried for participants, and only changes get sent - 'startParticipating' and 'stopParticipating' messages, which are SIGNALS, only sent once. I used to use 'join', 'disconnect' and 'leave', but all of those are dubiously named and often confused with other concepts. I think 'startParticipating' and 'stopParticipating', although long, are better names for these signals: startParticipating is a signal with the intent of promoting from spectator to participant stopParticipating is a signal that indicates that this participant stops participating, so either disconnects completely or just goes back to being a spectator. I think it ironically all comes down to this. If we can't trust the dev to filter their code properly, we could force the dev to use 'startParticipating' before inputs start to make an impact, and disconnects cause 'stopParticipating'. getInputForThisFrame(localGameState) { if(!localGameState.players.contains(myId)) { if(localGameState.players.length < max_players && join_button_pressed) { startParticipating(); } } return heldKeyDirection; // up, down, left, right... } Something like that would work just fine then. The dev from there also has an OPTION of cleaning up players him/herself by manually calling stopParticipating() if a participant intends to stop interacting with the game. the startParticipating sends a signal to the engine that you intend to join as a participant the game logic still has to poll for it. in the game loop: var playerIds = GetNewParticipants(limit = false) (limit = 2 means get max 2 playerIds) Any player id that the engine returns here, gets marked by the engine as participant. The previous definition of participant = has an input array is NOT true anymore. The new definition: The game engine has announced the player id to the game logic and NOT denounced it yet. NON-participants CAN now have input arrays. If they do, it merely means they're TRYING to be come a participant [{tick: 20, startParticipating: true}, {tick: 21, input: 'left'}, {tick: 25, input: 'right'}, {tick: 28, stopParticipating: true}, {tick: 36, startParticipating: true}, {tick: 37, input: 'up'}] This means: This node tries to start participating at tick 20, its input is 'left' from frame 21 onwards, changes to 'right' from tick 25 onwards, and the node intends to stop participating at tick 28. The node intends to start participating again at frame 36 and holds 'up' from frame 37 onwards. Whether this node ACTUALLY is a participant is marked by the engine. This only happens once this players id actually gets passed to the game logic. In the 20 players joining simultaneously example, this happens for only two nodes, the other 18 have input arrays like this, but don't become participants. Once a startParticipating input finalizes {tick: 20, participate: true} input gets dropped. If a participate input gets dropped, and for that inputs tick, the player did NOT become a participant, all inputs in the array until the next participate input (if any) get dropped. In the example above, this concretely means: at tick 20+grace window, the startParticipating input for tick 20 gets dropped. Hold on... Before I type out the rest of the system. I'm starting to make new realisations. The old design indeed was mixing up some things, but also was ingenious in some ways that this new design isn't. In this new design, 'startParticipating' is very one-dimensional signal: I want to join the game as a participant. In the old design, I was mixing the 'join signal' with 'queryable input', but the strength of this system was: conditional joining. Let's go back to the ball in the room example, but this time, make it a 3D room. 'pickUp' to pick up the ball was a very one dimensional signal, too. This time let's make it more interesting: You have a hand in 3D space in the game, and pressing a button does a "grab" action with your hand. Now, your input would probably look something like this: {grabAt: {x: 20, y: 50, z: 10}} The game logic would look something like this: if(ballHoldersId) // someone is holding the ball { if(queryInput(ballHoldersId, function(input) { return input == 'drop'})) // the player drops the ball { ballHoldersId = null; } } else // nobody is holding the ball { var frozenContext = {ballPosition: {x: ballX, y: ballY, z: ballZ}}; var limit = 1; // select at MOST one player ID var playerIds = getPlayersForQuery(frozenContext, function(context, input) { return input.grabAt && Math.abs(input.x - context.ballPosition.x) < 10 && Math.abs(input.y - context.ballPosition.y) < 10 && Math.abs(input.z - context.ballPosition.z) < 10;}, limit) // the player is grabbing where the ball is { if(playerIds.length > 0) { ballHoldersId = playerIds[0]; } } } Now, the magic in this design is that getPlayersForQuery, the "general query". It allows a "conditional join". A player's input only causes them to become an active participant if they are grabbing where the ball actually is. The game doesn't care about grabbing any other area. In my new design, I made 'startParticipating' so one dimensional, that this ingenious conditional joining doesn't work. However, the ADVANTAGE of the 'startParticipating' was that it was a clear signal to the engine that this is an action that can cause joining as a participant, where other inputs can't. In the conditional joining design, the problem is that the engine does NOT know which inputs could lead to joining. Inputs like 'left', 'right' can't lead to joining, only 'joinGame' can in that design, but the engine doesn't know that ahead of time. It's dependent on what is being queried, which is controlled by the game. That very limitation led me to make the NULL exception: NULL inputs cannot be general queried, thus cannot lead to joining. Although that's a valid exception, it doesn't solve that as long as the game is sending inputs, it can't be sure if they would lead to joining. To truly get the best of both worlds, an input would need to be queryable AND have a 'canLeadToParticipation' kind of flag for the engine. Wait, let me word that better. For the engine to smartly decide whether the player becomes a participant, it needs to know ahead of time whether a certain input could POTENTIALLY lead to the player joining the game. This is true if the getPlayersForQuery COULD return that players id. Inverting that principle, we can lock 'getPlayersForQuery' being able to run on an input behind a flag, let's call it: 'discoverable'. In the ball game the input would carry a 'discoverable' flag: {input: {grabAt: {x: 20, y: 50, z: 10}}, discoverable: true} In the 'joinGame' example: {input: 'joinGame', discoverable: true} // discoverable {input: 'left'} // not discoverable When running getPlayersForQuery(), only inputs where 'discoverable' is set CAN return that players id (and thus turn it into a participant). That means that inputs like 'left' and 'right' CANNOT lead to participation, but 'joinGame' CAN. Now, we don't have to wait for a NULL input to finalize to discard a player's input array. We can now wait for all 'discoverable' inputs to be finalized. If the player is still not a participant once the most recent one finalizes, the player's input array can be dropped as it did not become a participant.