]\exec wallhacks.cfg
You Cannot Hide a Player and Show Them at the Same Time
Server-side wallhack mitigation, OpenMoHAA, and the uncertainty between two packets
A server can hide an enemy from cheats by refusing to send that enemy. But the legitimate client still needs the data before the enemy walks around the corner, and it needs it early enough to receive it, interpolate it, and draw it. This post is my attempt to write down, with the OpenMoHAA source as evidence, exactly how long "early enough" is, why it is a random quantity rather than a constant, and how one might choose a disclosure policy when the measurements that would settle the question are themselves uncertain. --- This post is still a work in progress.
Contents
]\dir sections
- 1. Why I wrote this, and how to read it
- 2. The argument in one sentence
- 3. One corner, four clocks
- 4. The threshold and its price
- 5. What OpenMoHAA's server actually does
- 5.1 The tick, and two frame-time variables
- 5.2 Building a snapshot: the viewpoint and the client's own entity
- 5.3 The gate order
- 5.4 Near, far, and in between:
EntityDistCheckand the two modes - 5.5
SV_ClientIsVisible, line by line - 5.6 What "line of sight" means to the server
- 5.7 The grace timer
- 5.8 Sounds for the unseen
- 5.9 What three server frames are, and are not
- 5.10 To the wire, and the knobs
- 6. What the client does with a late player
- 7. Where OpenMoHAA departs from ioquake3
- 8. Observations that deserve fixtures
- 9. Modelling the corner probabilistically
- 10. What to log, and how not to fool ourselves
- 11. Related work: from misplaced trust to cryptographic fog
- 12. Where this leaves me
- AI Disclaimer
- References
1. Why I wrote this, and how to read it
This post is my attempt to understand, and then explain, my current conceptual model of how Medal of Honor: Allied Assault (MOHAA) netcode behaves when a server tries to hide players from clients that should not see them yet. I chose MOHAA because it is the first-person shooter and the codebase I know best, not because it is special: the open-source reimplementation OpenMoHAA inherits the Quake III / ioquake3 networking skeleton, and almost everything below applies, with different names and constants, to any authoritative client/server shooter. It is not official OpenMoHAA documentation, it does not speak for the project's developers, and it should never be read as such. Where I quote source code I quote a pinned revision and link to it, so that anyone can check whether I read it correctly.
I also want to be honest about the epistemic status of what follows. I have read the code, traced the paths by hand, and written down the math myself, but I have not yet instrumented a live server to measure the quantities this post is ultimately about. I am fully aware that my interpretation may be incomplete or mistaken in places, and some observations I flag are exactly the kind of thing that looks like a bug on paper and turns out to be intentional once you know the history. So, keep that in mind. In any case, corrections and suggestions are all welcome. Just reach out to me on Discord.
The question is old and most of the ingredients are all public. Cheating in online games has been described as a problem of misplaced trust since at least Yan and Randell's taxonomy [1] (but of course it is as old as games exist): a wallhack works because the client is handed state it is not entitled to use, and the client is not trustworthy. Studies of state exposure ask how far ahead of legitimate need that state is preloaded [2, 3]. Server-side visibility filtering, the family this post is about, answers by not sending the state at all until it is needed, in forms ranging from MOHAA's own 2.30 patch and OpenMoHAA's reimplementation [4] to community occlusion culling for Source [5] and Riot's Fog of War for VALORANT [6]. Snapshot interpolation and client prediction, the machinery that decides how early "needed" is, were laid out by Bernier [7], the Valve wiki [8], Gambetta [9], and van Waveren [10]. Probabilistic checking of clients [11], cryptographic hidden-state protocols [12, 13], and trusted client execution [3] attack the same trust problem from other sides. And Bayesian modelling under uncertain, imperfect measurements is a mature field with good textbooks [14, 15].
Existing work studies information exposure, state dissemination, interpolation delay, visibility filtering, probabilistic verification, and secure hidden-state handling. A unified probabilistic treatment of server disclosure, cheat accessibility, true visibility, and first legitimate rendering appears less developed in the public FPS literature. I do not claim novelty for any single ingredient. What I try to contribute is to the scientific knowledge of netcode itself through a (optional to this post) mathematical lens.
How to read this. The post is written for three audiences at once, and it is fine to skip what is not for you.
- Level 1 Players and curious non-programmers: sections 2, 3, 4, and 12, plus the figures. No code, and the mathematics is two subtractions.
- Level 2 OpenMoHAA and engine developers: sections 5 to 8, 10, and 11. Each claim about the engine sits next to a verbatim, commit-pinned excerpt ("Listing N") with notes on what the excerpt does and does not prove.
- Level 3 Readers who want the mathematics: the blue expandable boxes. Every box starts with one sentence saying which question it answers, so you can decide whether to open it.
Nothing in a blue box is needed to follow the main text.
2. The argument in one sentence Level 1
A server-side anti-wallhack system works by withholding an enemy's state while that enemy is safely hidden. The server must nevertheless release the state early enough for the real client to receive and render it before geometric visibility. Those two sentences are the whole post; everything else is about how large "early enough" is, why it is not a constant, and what a codebase that actually implements this does about it.
Withhold for longer and the cheat learns less. Withhold for longer and the legitimate client has less time to prepare.
Here is the example I will keep coming back to. Two players, a defender holding an angle and an attacker running towards the corner that separates them. While the attacker is behind the wall, the defender's game does not need to know where the attacker is. If the server sends the attacker's position anyway, a wallhack on the defender's machine can draw it through the wall. If the server refuses to send it, the wallhack has nothing to draw. But the moment the attacker steps around the corner, the defender's game must draw them at once, and it cannot draw what it has not received. Packets take time to travel, and the game does not draw a packet the instant it arrives; it waits until it can place the packet in time between its neighbours. So the server has to release the attacker's position a little before the attacker is actually visible. During that "little before", a cheat can see through the wall. After it, if the release was too late, the legitimate defender sees the attacker appear out of thin air, already aiming.
What a wallhack sees, on two kinds of server
The same instant, drawn by a wallhack on the defender's machine. The attacker is behind the wall in both panels; the only difference is what the server put in the packets.
A wallhack can only draw what the server sent. The traditional rule sends any enemy who could in principle be seen from your part of the map (section 3.1 explains that test); a withholding server, such as OpenMoHAA under sv_netoptimize, keeps the enemy out of your packets until just before they actually step out. The price of "just before" is the subject of this post.
There is no setting that makes both problems vanish. Every state-withholding implementation has the same kind of timing tradeoff, whether it is a 2003 patch for a World War II shooter or a 2020 fog-of-war system in a competitive title. The tradeoff can be moved, measured, and optimized, but it cannot be switched off, and I think treating it as a switch ("just enable anti-wallhack") is why discussions about it tend to go badly. The rest of the post tries to replace the switch with a number, then to replace the number with a distribution, and finally to say how the distribution could be measured on a real server (that would be very cool).
Two things this post is not about. It is not about aimbots, triggerbots, or any cheat that acts on state the client legitimately has; withholding cannot help there. And it is not about the general anti-cheat question of detecting modified clients [16]. It is about one narrow, well-posed thing: the timing of disclosure. Finally, if you are reading this in the hope of learning how bypass an anti-cheat, I have bad news: anti-cheats do not share the same limitations as client-based cheats since they can rely on server-side information. Good luck with that.
3. One corner, four clocks
3.1 What a snapshot is, and why everyone else lives in the past Level 1
In the Quake III family of engines, and therefore in MOHAA and OpenMoHAA, the server is the
only authority on where anyone is. It advances the world in fixed steps: at the default
sv_fps 20 that is one step every 50 ms. After each step it builds, for each
connected player, a snapshot: a compact description of that player's own state plus
the entities the server has decided that player should know about, and it sends the snapshot
as a UDP packet [17, 18, 19]. Snapshots are delta-compressed against
the last one the client acknowledged, so a lost packet costs a little bandwidth rather than a
resend [20].
Your machine, meanwhile, draws far more than twenty frames per second, and it has to draw other players at moments for which no snapshot exists. The standard answer, which Bernier wrote up for Half-Life [7] and Valve later documented in detail [8], is interpolation: the client deliberately holds its notion of "now" a little behind the newest snapshot, so that for any rendered frame it has one snapshot just before and one just after, and it blends the two. Other players are therefore always drawn slightly in the past. Your own player is the exception: to keep controls responsive, the client runs your inputs through the same movement code the server uses and shows you the predicted result immediately, correcting itself if the server later disagrees [9, 21]. Gambetta's articles come with a small live demo of both mechanisms, and if you want the MOHAA-shaped version of the same picture, I built one here.
Now the wallhack. Which entities go into your snapshot is decided by the server, and the
traditional criterion is coarse: the map is pre-divided into regions, and the compiler records
which regions could possibly see which others, the potentially visible set
[22]. An enemy in a region that could in principle be seen from yours is sent, even
if right now a crate, a doorway, or a thin interior wall is between you. The enemy's position
is then sitting in your machine's memory. A wallhack is nothing more than a program that reads
that memory and draws what it finds. The server cannot stop your machine from reading its own
memory; the only lever it has is to not send the data. That lever is what MOHAA 2.30 pulled,
and what OpenMoHAA reimplements under the name sv_netoptimize
[4, 23]. The trouble is that "not sending" collides with
"interpolating", because interpolation needs the data before it is needed on
screen. If you would like to see a modern production version of the idea before diving
into MOHAA's, Riot's Fog of War write-up [6]
demonstrates it with short animated clips of an enemy being withheld and then revealed.
3.2 The corner, step by step Level 1
Take the defender and the attacker from section 2, and watch one crossing from the defender's point of view. Four moments matter, and I will give each a letter.
- S, the server releases. On some server step the server decides the attacker now belongs in the defender's snapshot. In OpenMoHAA that decision is a geometric test that runs a short distance into the future (section 5); in other systems it may be a fixed lead. Either way, S is the first snapshot that carries the attacker.
- A, the packet arrives. Some tens of milliseconds later, the snapshot reaches the defender's machine and is parsed. From this instant the attacker's position is in memory. A cheat can use it now.
- R, the game first draws the attacker. The legitimate client does not draw a snapshot the moment it arrives. It waits until its own clock reaches the snapshot's time, so that it can blend towards the next one; then it needs a frame to render and the display needs a moment to show it. R is the first frame in which a human could see the attacker.
- V, the corner opens. The attacker's body actually crosses the line of sight. This moment is decided by the two players' movement, not by any software.
Everything interesting is in the order of these four moments. If V comes after R, the defender saw the attacker in time, and the cheat had from A to V to look through the wall. If V comes before R, the attacker popped into existence late by R minus V, and the cheat had less, possibly nothing. Figure 1 lets you move the three quantities that decide the order.
Figure 1 · The same corner on three screens
The red attacker walks down a corridor and around the wall towards you (blue). The server knows where they are the whole time. Watch when it starts sending them, when that packet lands in your machine's memory, which is all a wallhack needs, and when your screen finally draws them. Press play, or drag the knob on the time bar.
Figure 1. Time zero is the moment the corner opens (\(V\)). The server starts sending the attacker \(H\) ms before that (\(S\)); the packet lands \(D_A\) ms later (\(A\)), and from that instant a wallhack can draw the attacker through the wall; your screen waits another \(B\) ms (\(R\)). If the packet lands before the corner opens, the wallhack gets a head start, the cheat window \(W\); if your screen draws the attacker after the corner opens, you see them late by \(L\). Grey dots on the wire are snapshots without the attacker, red ones carry them, one every 50 ms as at the 20 Hz default of section 5. Every panel draws the attacker where they really are: the figure is about when they appear, not where.
3.3 Four clocks, three delays, one margin Level 1 Level 3
I will use these symbols for the rest of the post. All times are on the server's clock; the client's clock enters only through the delays, which is the point of choosing this frame.
| Symbol | Meaning | Decided by |
|---|---|---|
| \(S\) | server release: the first snapshot that carries the target | server policy and the two players' geometry |
| \(A\) | arrival: that snapshot is parsed on the viewer's machine; a cheat can use it from here | the network |
| \(R\) | first legitimate render of the target | the client's clock policy, frame rate, and display |
| \(V\) | true geometric visibility: a line of sight exists | the players |
| \(D_A = A - S\) | delivery delay | |
| \(B = R - A\) | render delay after arrival | |
| \(D_R = R - S = D_A + B\) | total delay from release to first render | |
| \(H = V - S\) | disclosure lead: how early the server released, relative to true visibility | |
| \(G_A = V - A = H - D_A\) | the cheat's lead (negative means the cheat learned nothing early) | |
| \(G_R = V - R = H - D_R\) | the legitimate margin (negative means the target appeared late) | |
| \(W = [G_A]_+ = \max(H - D_A, 0)\) | the cheat's window | |
| \(L = [-G_R]_+ = \max(D_R - H, 0)\) | the legitimate lateness |
Three remarks. First, the bracket \([x]_+\) is shorthand for \(\max(x, 0)\), the positive part of \(x\): a window cannot be negative, and neither can a lateness, so both are clipped at zero. Second, \(H\) is the only quantity the server controls, and even that only indirectly: it chooses a rule, and the rule plus the geometry produces \(H\). Third, the two outcomes are driven by the same \(H\) in opposite directions, through delays the server does not choose and cannot observe exactly. That is the entire tension, and section 4 makes it precise.
Level 3Interpolation as a convex combination, and why \(B\) delays viewing but not access
Question: what exactly does the legitimate client compute between two snapshots, and at what moment does the data it needs become available in memory?
Let the server sample a remote player's position at snapshot times \(t_k\), giving points \(u_k \in \mathbb{R}^3\) (I write \(u\) for positions throughout the blue boxes). For a render time \(t \in [t_k, t_{k+1})\) the client forms
$$ r(t) = (1-f)\,u_k + f\,u_{k+1}, \qquad f = \frac{t - t_k}{t_{k+1} - t_k} \in [0,1), \tag{1} $$a convex combination of the two samples: \(r(t)\) sits on the straight segment between them and slides from \(u_k\) to \(u_{k+1}\) as \(f\) goes from 0 to 1. Because the two weights add up to one, the recipe commutes with translations and rotations of the map, which is why the same code works for every corner. How wrong can the straight segment be? If the true path \(u(\cdot)\) is twice differentiable on \([t_k, t_{k+1}]\), the standard error bound for linear interpolation gives
$$ \|u(t) - r(t)\| \;\le\; \frac{(t_{k+1}-t_k)^2}{8}\,\max_{s\in[t_k,t_{k+1}]}\|\ddot u(s)\|, \tag{2} $$where \(\ddot u\) is the acceleration along the true path. The numbers are worth plugging in once. At the default snapshot rate the spacing is \(t_{k+1} - t_k = 50\) ms \(= 0.05\) s, so the prefactor is \(0.05^2/8 \approx 0.0003\) s\(^2\). A hard strafe reversal is an acceleration of roughly 3000 units/s\(^2\), and \(0.0003 \times 3000 \approx 0.94\) units. A player model is about 30 units wide, so even the sharpest turn a player can make displaces the interpolated body by a thirtieth of its width: as a rendering method, interpolation is essentially exact.
What is not small is the time cost. The renderer needs \(u_{k+1}\) to draw any \(t > t_k\), so it must wait until the snapshot carrying \(u_{k+1}\) has arrived and its own clock has advanced to \(t_k\). That waiting is the render delay \(B\), and it binds only the legitimate path: a cheat reads the position out of memory the moment the snapshot is parsed, at \(A\), and waits for nothing. Withholding on the server moves \(A\); nothing on the client can make \(B\) negative.
4. The threshold and its price
4.1 Two margins that cannot both be large Level 1
Put the two outcomes side by side. The cheat's window is \(W = \max(H - D_A, 0)\): every millisecond of lead beyond the delivery delay is a millisecond of wall-vision. The legitimate lateness is \(L = \max(D_A + B - H, 0)\): every millisecond of lead short of the full delay to first render is a millisecond during which a visible enemy is not on screen. Making \(W\) zero requires \(H \le D_A\). Making \(L\) zero requires \(H \ge D_A + B\). Both at once require \(B \le 0\), and \(B\) is a waiting time, so it is never negative and in practice never zero. The title of the post is a theorem with a one-line proof: for any client whose render delay is positive, no disclosure lead makes both the cheat's window and the legitimate lateness vanish.
The corner in Figure 1 shows what the trade looks like at the level of a single crossing. In the interval \(D_A < H < D_A + B\), the cheat sees the attacker \(H - D_A\) early and the defender still sees them \(D_A + B - H\) late; both are true at once, and the sum \(W + L = B\) does not depend on \(H\) at all. All the server can do in that band is decide who pays for \(B\). Outside the band, one of the two costs is zero and the other grows linearly.
4.2 Why it is a trade, not a switch Level 1 Level 3
If every client had the same delays, the trade would still exist but it would be boring: pick \(H\) somewhere in the band and argue about it once. What makes it interesting is that \(D_A\) and \(B\) differ across clients, across time for one client, and across corners on the same map. A player on fibre in the same city as the server has \(D_A\) near 15 ms; a player on mobile data two countries away has 90 ms with spikes. The server sets one rule. For the near player the rule leaks; for the far player the same rule pops. Once the delays are random variables, the sensible question is not "which \(H\) is right" but "which \(H\) minimises the expected cost, given what I believe about the delays and how much I dislike each kind of failure". That is a decision problem, and it has a clean answer when the costs are linear.
Level 3The optimal lead is a quantile of the delay distribution
Question: if the delays are random and I pay \(c_W\) per millisecond of cheat window and \(c_L\) per millisecond of lateness, which lead \(h\) minimises expected cost?
Write \(D_A\) for the random delivery delay of a crossing and \(D_R = D_A + B\) for the random delay to first render, with distribution functions \(F_{D_A}\) and \(F_{D_R}\), assumed continuous and strictly increasing so that quantiles are unambiguous. If the server releases with lead \(h\), the crossing produces a cheat window \([h - D_A]_+\) and a lateness \([D_R - h]_+\), so the expected cost is
$$ J(h) = c_W\,\mathbb{E}\big[[h - D_A]_+\big] + c_L\,\mathbb{E}\big[[D_R - h]_+\big]. \tag{3} $$Now nudge the lead. One more millisecond of \(h\) adds a millisecond of window on every crossing that was already delivered in time (\(D_A < h\)) and removes a millisecond of lateness on every crossing that was still going to be late (\(D_R > h\)). So the marginal cost of the nudge is
$$ J'(h) = c_W\,\mathbb{P}(D_A < h) - c_L\,\mathbb{P}(D_R > h) = c_W F_{D_A}(h) - c_L\big(1 - F_{D_R}(h)\big). \tag{4} $$As \(h\) grows, the first probability rises and the second falls, so \(J'\) only ever increases; it is negative for small \(h\) (everything is late, releasing earlier only helps) and positive for large \(h\) (everything leaks, releasing earlier only hurts), so it crosses zero once, and that crossing is the global minimum. There the two sides balance: \(c_W F_{D_A}(h^*) = c_L\,(1 - F_{D_R}(h^*))\). In the special case where the render delay is small compared with the spread of delivery, so that \(D_A \approx D_R \equiv D\), this collapses to the balance condition
$$ F_D(h^*) = \frac{c_L}{c_W + c_L}. \qquad\blacksquare \tag{5} $$Read it as a fraction: if lateness costs three times as much as leakage, release early enough that three quarters of crossings are delivered in time. Two consequences matter for the rest of the post. First, the optimum depends on the whole distribution of the delays, not on their mean; a heavy upper tail moves \(h^*\) a lot. Second, \(h^*\) is defined per population: if delays differ by client, the same rule gives a different \(J\) to each, and a per-client rule (section 5.9, box D10) can do strictly better than any single \(h\).
Level 3When tail targets for both sides are infeasible
Question: can I demand that the cheat window rarely exceed \(w\) and that lateness rarely exceed \(\ell\), and what does "rarely" cost?
Suppose an operator writes down two service targets: the cheat window may exceed some tolerated \(w\) (say 30 ms) on at most a fraction \(\delta\) of crossings, and the lateness may exceed \(\ell\) on at most a fraction \(\varepsilon\):
$$ \mathbb{P}(W > w) \le \delta \qquad\text{and}\qquad \mathbb{P}(L > \ell) \le \varepsilon. \tag{6} $$One piece of notation, because everything below is quantiles. \(F_{D_A}^{-1}(q)\) is the delay that a fraction \(q\) of crossings stays below: \(F_{D_A}^{-1}(0.05)\) is a fast delivery, beaten only by one crossing in twenty, and \(F_{D_R}^{-1}(0.95)\) is a slow first render, exceeded only by one in twenty. As in box D2, I take the distribution functions continuous and strictly increasing, so these are single well-defined numbers. Now take the two targets one at a time, for a fixed lead \(H\).
The window target caps \(H\) from above. The window exceeds \(w\) exactly when the release beats the delivery by more than \(w\): \(W = [H - D_A]_+ > w \iff D_A < H - w\). The probability of that event is \(F_{D_A}(H - w)\), and demanding it be at most \(\delta\) says \(H - w\) must not reach past the fast \(\delta\)-quantile of delivery:
$$ H \;\le\; F_{D_A}^{-1}(\delta) + w. \tag{7} $$In words: the lead may exceed the tolerated window only on the very fastest deliveries, because those are exactly the crossings on which the cheat gets more than \(w\).
The lateness target props \(H\) up from below. The lateness exceeds \(\ell\) exactly when the delay to first render beats the lead by more than \(\ell\): \(L = [D_R - H]_+ > \ell \iff D_R > H + \ell\). The probability of that is \(1 - F_{D_R}(H + \ell)\), and demanding it be at most \(\varepsilon\) says \(H + \ell\) must reach at least the slow \((1-\varepsilon)\)-quantile of the render delay:
$$ H \;\ge\; F_{D_R}^{-1}(1 - \varepsilon) - \ell. \tag{8} $$In words: the lead must cover even the slowest renders, short of them by at most the tolerated \(\ell\).
A ceiling and a floor. A lead satisfying both targets exists if and only if the floor sits below the ceiling,
$$ F_{D_R}^{-1}(1-\varepsilon) - \ell \;\le\; F_{D_A}^{-1}(\delta) + w, \qquad\text{equivalently}\qquad w + \ell \;\ge\; F_{D_R}^{-1}(1-\varepsilon) - F_{D_A}^{-1}(\delta). \tag{9} $$The right side of the second form is a slow quantile of the larger delay \(D_R = D_A + B\) minus a fast quantile of the smaller one, so it is at least the \(\delta\)-to-\((1-\varepsilon)\) spread of the delivery delay alone; if the render delay were a constant \(b\), it would be exactly that spread plus \(b\). Round numbers: with \(\delta = \varepsilon = 0.05\), a delivery delay whose 5th-to-95th percentile spread is 60 ms, and \(B\) around 50 ms, the right side is about 110 ms, and the targets \(w = \ell = 30\) ms offer only 60. No lead exists; the demands were contradictory before any rule was chosen. This is the theorem of section 4.1 again, in the language of quantiles, and it is why I think the right output of the analysis is a curve of feasible pairs, not a single setting.
5. What OpenMoHAA's server actually does Level 2
Everything in this section is read from one revision of the OpenMoHAA repository, commit
667c50f9
of 2026-04-13, and every listing links to that commit rather than
to a moving branch. I quote the smallest range that supports a claim, I never edit the
quoted text, and under each listing I say what I think it shows and what it does not.
If the project changes any of this, the listings will still be true of that commit and
possibly false of the game you are running; check the date.
The feature has no "wallhack" in its name anywhere in the code or commit history. The project documentation calls it network optimization, files it under a heading that also says "Antichams", and traces it to the 2.30 patch of Allied Assault Breakthrough [4, 23]:
### Optimization / AntichamsA new variable, `sv_netoptimize`, enables a feature that optimizes network bandwidth by not sending players information about others they can't see. For each client, the server optimizes by only transmitting data about players within their view. Clients will not receive information about players they can't see. This feature also helps protect against cheaters:- `set sv_netoptimize 0`: Disable optimization - the default- `set sv_netoptimize 1`: Enable optimization for entities that are moving- `set sv_netoptimize 2`: Enable optimization, alwaysThis option exists since **Medal of Honor: Allied Assault Breakthrough** 2.30, however it was improved in OpenMoHAA: sounds like footsteps will be sent so players don't get confused.
From docs/markdown/03-configuration/01-configuration.md:59–67 at the pinned commit. The differences page says the same in fewer words: 04-differences.md:334–338.
I read that description as a bandwidth feature that happens to hide information, and the code below is consistent with that reading: the filter is careful about not making the game feel wrong (a grace timer, a look-ahead, sounds for the hidden) and only incidentally careful about what a cheat can learn. That is not a criticism. It is the reason the timing question of section 4 is the right question to ask of it.
5.1 The server tick and the two frame times
The server advances in whole frames of frameMsec milliseconds and stamps
every snapshot with the integer time of the last completed frame. There is a second
frame-time variable, sv.frameTime, which is the same quantity in seconds as a
float; the visibility code multiplies it by three, so it pays to be clear about its
units.
if ( sv_fps->integer < 1 ) { Cvar_Set( "sv_fps", "20" ); } frameMsec = 1000 / sv_fps->integer; // don't let it scale below 1ms if(frameMsec < 1) { Cvar_Set("timescale", va("%f", sv_fps->integer / 1000.0f)); frameMsec = 1; } sv.timeResidual += msec; while ( sv.timeResidual >= frameMsec ) { sv.timeResidual -= frameMsec; svs.time += frameMsec; if( sv.state == SS_GAME ) { const char *err; // let everything in the world think and move ge->RunFrame( svs.time, frameMsec ); // send messages back to the clients SV_SendClientMessages(); // Added in OPM // Handle non-pvs sounds SV_HandleNonPVSSound(); svs.lastTime = svs.time; Com_Memset (&sv, 0, sizeof(sv)); sv.frameTime = 1.0 / sv_fps->value;- L1074
frameMsecis an integer number of milliseconds: 1000 / 20 = 50 at the defaultsv_fps 20. Every server frame, and therefore every snapshot timestamp, lands on a multiple of it. - L1082, L1126–L1128 Wall-clock time accumulates in
sv.timeResidual; the loop consumes whole frames, advancingsvs.timebyframeMseceach time and running the game logic (ge->RunFrame) that moves players. - L1154 Snapshots are built and sent after every frame due in this call has run, so a snapshot's time is the time of the last completed frame, not of the socket write.
- L1164 The non-PVS sound queue (§5.8) is flushed once per
SV_Frame; this call is an OpenMoHAA addition. - sv_init.c L552
sv.frameTimeis1.0 / sv_fpsin seconds (0.05 at 20 Hz), set insideSV_ClearServerright after the wholesvstruct is zeroed. It is a float;frameMsecis an int.
Shows: the first disclosure time \(S\) is quantised to the server frame (50 ms by default), and that the two frame-time variables have different units. Does not show: how much wall-clock jitter the loop suffers between calls, or when a given packet actually leaves the socket; both depend on the host loop and on the per-client rate gating in SV_SendClientMessages.
For the timing model this fixes the resolution of \(S\): a player can only become disclosed on a frame boundary, so \(S\) is a multiple of 50 ms at the default rate. It also fixes the resolution of what the server can know about \(V\): the trace runs on positions the game logic produced for this frame, never in between.
5.2 Building one client's snapshot: the viewpoint
SV_BuildClientSnapshot is called once per client per send. It copies the
client's own player state, picks a viewpoint, and calls the entity walk of section 5.3
from that viewpoint. Two details matter for us: which point is used, and the fact that
the client's own entity is in the set.
ps = SV_GameClientNum( client - svs.clients ); frame->ps = *ps; frame->ps.net_pm_flags = CPT_DenormalizePlayerStateFlags(ps->pm_flags); frame->ps.iNetViewModelAnim = CPT_DenormalizeViewModelAnim(ps->iViewModelAnim); //SV_SvEntityForGentity // never send client's own entity, because it can // be regenerated from the playerstate clientNum = frame->ps.clientNum; if ( clientNum < 0 || clientNum >= MAX_GENTITIES ) { Com_Error( ERR_DROP, "SV_SvEntityForGentity: bad gEnt" ); } svEnt = &sv.svEntities[ clientNum ]; // su44: that's not done in MoHAA //svEnt->snapshotCounter = sv.snapshotCounter; // find the client's viewpoint if (ps->pm_flags & PMF_CAMERA_VIEW) { VectorCopy(ps->camera_origin, org); VectorCopy(ps->camera_angles, ang); } else { VectorCopy(ps->vEyePos, org); VectorCopy(ps->viewangles, ang); } SV_AddEntToSnapshot(svEnt, SV_GentityNum(client - svs.clients), &entityNumbers, NULL, qfalse);- L1069–L1072 A snapshot starts as a copy of the viewer's own
playerState_t, with two MOHAA-specific fields de-normalised for the wire. - L1075–L1076 vs L1098 The inherited Quake III comment still says the client's own entity is never sent; OpenMoHAA adds it anyway at L1098, and su44's note at L1083–L1084 records that the ioquake3
snapshotCountertrick "is not done in MoHAA" (fixture F9). - L1086–L1096 The viewpoint is the camera origin and angles under
PMF_CAMERA_VIEW, otherwise the eye positionvEyePosand the view angles. Theseorg/angvalues are what every later test (area, far plane, PVS, line of sight) is evaluated from.
Shows: from which point and in which direction the server evaluates visibility, and that a player's own entity is included in their own snapshot. Does not show: anything about the bandwidth cost of that inclusion, or about what the client does with the extra entity.
5.3 The gates, in order
SV_AddEntitiesVisibleFromPoint walks every entity and applies a sequence of
cheap-to-expensive tests. The head of the loop is inherited almost verbatim from
Quake III: skip entities not in use or already sent, honour SVF_NOCLIENT,
SVF_SINGLECLIENT and SVF_NOTSINGLECLIENT
(sv_snapshot.c:643–669), with the ioquake3 SVF_CLIENTMASK
block commented out and marked "Removed in OPM" (sv_snapshot.c:670–680).
Then come MOHAA additions: an entity attached to a parent is judged by its parent's
visibility (sv_snapshot.c:682–689, sv_snapshot.c:716–729),
sky-origin and always-draw entities are broadcast
(sv_snapshot.c:698–714). The tail is where players are treated
differently from everything else.
// ignore if not touching a PV leaf // check area if ( !CM_AreasConnected( clientarea, svCheckEnt->areanum ) ) { // doors can legally straddle two areas, so // we may need to check another one if ( !CM_AreasConnected( clientarea, svCheckEnt->areanum2 ) ) { continue; // blocked by a door } } if (g_gametype->integer != GT_SINGLE_PLAYER && !(ent->r.svFlags & SVF_NOFARPLANE)) { float farplane = sv.farplane; if (farplane < 1) farplane = 12000; if (farplane > 12000) farplane = 12000; check = EntityDistCheck(origin, forward, parentEnt ? parentEnt : ent, farplane, ps->fov); if (check == CULL_OUT) { continue; } } // check individual leafs if( !svEnt->numClusters ) { continue; }/* … L769–L794 omitted: per-cluster PVS bit test, including the lastCluster overflow case … */ if (svEnt->snapshotCounter == sv.snapshotCounter) { ent->s.renderfx &= ~(RF_WRAP_FRAMES | RF_SHADOW_PLANE); ent->s.renderfx |= RF_WRAP_FRAMES; continue; } if (g_gametype->integer != GT_SINGLE_PLAYER && ent->s.number < svs.iNumClients) { if (!SV_ClientIsVisible(ent->s.number, client - svs.clients, check, forward, right)) { SV_AddNonPVSSound(client, ent); continue; } } // add it SV_AddEntToSnapshot( svEnt, ent, eNums, portalEnt, portalsky); // if its a portal entity, add everything visible from its camera position if ( ent->r.svFlags & SVF_PORTAL && svEnt != portalEnt ) { SV_AddEntitiesVisibleFromPoint( ent->s.origin2, frame, eNums, svEnt, qfalse, client, angles ); } } if (!portalsky && skyorigin && !portalEnt) { SV_AddEntitiesVisibleFromPoint(skyorigin->s.origin, frame, eNums, NULL, qtrue, client, angles); }}- L742–L750 Area connectivity: an entity behind a closed areaportal ("blocked by a door") is skipped before any geometry test.
- L752–L761 Multiplayer only, and only for entities without
SVF_NOFARPLANE:sv.farplaneis clamped to [1, 12000] andEntityDistCheck(Listing 4) classifies the entity (or its parent) asCULL_IN,CULL_CLIPorCULL_OUT; onlyCULL_OUTskips here. - L764–L767, elided block Stock Quake III PVS: if none of the entity's clusters is in the viewer's cluster PVS, skip.
- L796–L800 Already added through a portal this frame: fix up the render flags and skip (no second copy).
- L802–L807 The OpenMoHAA-specific gate. Only for multiplayer player entities (
s.number < svs.iNumClients): ifSV_ClientIsVisible(Listing 5) says no, the player's queued non-PVS sounds are forwarded (Listing 7) and the entity is left out of the snapshot. - L810 Everything that survived is added.
- L813–L820 Portal and sky-portal recursion re-enter this function from the portal's origin but with the outer viewer's
angles(fixture F6).
Shows: the exact order of the gates, and that the line-of-sight test is applied to player entities only, after PVS. Does not show: that non-player entities are ever traced (they are not), or how often the PVS gate alone already removes a player on a given map.
Figure 2 · The order of the gates for one entity
Each box is a test the entity must pass to reach the snapshot. Grey gates exist in ioquake3; teal gates are OpenMoHAA additions; the rose gate is the only one that runs a line-of-sight trace, and only for player entities.
Figure 2. Drawn from Listing 3 and the loop head at sv_snapshot.c:639–740. The trace is the last test, so it only ever runs on players who are already in the viewer's potentially visible set, within the far plane, and not behind a closed areaportal.
5.4 Near, far, and in between: EntityDistCheck and the two modes
Before the potentially visible set is consulted, every entity is classified by distance
from the viewpoint, scaled by the viewer's field of view and by whether a player is
moving. The classification is not just a far-plane cut: its CULL_IN result
is what mode 1 of the filter uses to skip the trace entirely.
int EntityDistCheck(const vec3_t origin, const vec3_t forward, const gentity_t* ent, float farplane, float fov) { vec3_t dir; float farplaneMax; float length; float fovCheck; float dot; VectorSubtract(origin, ent->r.centroid, dir); length = VectorNormalize(dir) - ent->r.radius; farplaneMax = farplane + 128; if (length >= farplaneMax) { return CULL_OUT; } fovCheck = fov / 80 * length; if (ent->s.number < svs.iNumClients && VectorLength(ent->s.pos.trDelta) < 5) { fovCheck *= 0.25f; } if (fovCheck <= 640) { return CULL_IN; } dot = DotProduct(forward, dir) + 0.5f; if (dot < 0) { dot = 0; } if (fovCheck * (dot * 2.f + 1.f) >= farplaneMax) { // outside the farplane return CULL_OUT; } return CULL_CLIP;}- L559–L560
dirpoints from the entity's centroid toward the viewer (centroid subtracted from origin);lengthis that distance minus the entity's bounding radius. - L562–L565 Hard cut: anything at or beyond
farplane + 128isCULL_OUT. - L567–L571
fovCheckscales distance byfov / 80; a player whosepos.trDeltais shorter than 5 units/s is counted at a quarter of the distance. Stationary players are therefore treated as nearer, which makes themCULL_INup to four times farther away. - L573–L575
CULL_INinside 640 units at fov 80 for a moving player, 2560 for a stationary one. - L577–L585 The view-direction term uses the same centroid-to-viewer
dir, offset by +0.5 and clamped at 0;CULL_OUTwhenfovCheck·(2·dot + 1) ≥ farplaneMax. Everything else isCULL_CLIP, which the caller treats as "still in".
Shows: the three regions and their radii, that stationary players are pulled inward, and that the direction term is computed with dir pointing from the entity to the viewer. Does not show: what units sv.farplane arrives in (fixture F7) or whether the sign of dir is the intended one (fixture F5). Both deserve a deterministic test rather than a verdict.
Figure 3 · Where a player is "in", "clipped", or "out"
A top-down transliteration of EntityDistCheck for a viewer at the centre looking right. Move the field of view and far plane, and toggle whether the target is stationary. In mode 1, a target in the green region is disclosed without any line-of-sight test.
Figure 3. The formula is the one in Listing 4, evaluated with the viewer's forward vector along +x and the entity radius set to zero. The far-plane slider is fed in linearly, as the clamp at sv_snapshot.c:755–756 assumes; whether the stored value is linear is fixture F7 below.
On the far plane itself: the clamp accepts anything in \([1, 12000]\) and maps
zero to 12000, so a map that never sets a far plane is culled at 12128 units,
which on most MOHAA maps is "never". The value it clamps is written by
SV_SetFarPlane, and that function stores a square:
void SV_SetFarPlane( int farplane ){ if( farplane ) { sv.farplane = ( farplane + 32 ) * ( farplane + 32 ); } else { sv.farplane = 0; }
sv_game.c:1469–1478. A far plane of 4000 is stored as \(4032^2 \approx 1.6 \times 10^7\), which the clamp reduces to 12000, and EntityDistCheck then compares against a linear length. Either the game module always passes a value the clamp is meant to swallow, or the two sides disagree about units. This is fixture F7; I do not know which it is, and a deterministic test would settle it in an afternoon.
5.5 SV_ClientIsVisible, line by line
This is the function the whole post is about. It is called with the target's slot first and the viewer's slot second, and with the viewer's forward vector as computed from the snapshot viewpoint of section 5.2. I quote it whole, because its control flow is the argument.
qboolean SV_ClientIsVisible(int toNum, int fromNum, int distCheck, const vec3_t forward, const vec3_t right) { client_t* fromClient; playerState_t *fromPs, *toPs; vec3_t dir; vec3_t fromOrigin, toOrigin; vec3_t toRight; float dot; float speed; if (!g_netoptimize->integer || !sv_netoptimize->integer) { return qtrue; } if (toNum >= svs.iNumClients) { return qtrue; } fromClient = &svs.clients[fromNum]; if (sv_netoptimize->integer == NETO_CULLED && distCheck == CULL_IN) { fromClient->lastVisCheckTime[toNum] = svs.time + sv_netoptimize_vistime->integer; return qtrue; } if (fromClient->lastVisCheckTime[toNum] > svs.time) { return qtrue; } fromPs = SV_GameClientNum(fromNum); toPs = SV_GameClientNum(toNum); if (fromPs->fLeanAngle == 0) { VectorCopy(fromPs->vEyePos, fromOrigin); } else if (fromPs->fLeanAngle >= 0) { VectorMA(fromPs->vEyePos, 30, right, fromOrigin); } else { VectorMA(fromPs->vEyePos, -30, right, fromOrigin); } VectorCopy(fromPs->vEyePos, fromOrigin); if (toPs->fLeanAngle == 0) { VectorCopy(toPs->vEyePos, toOrigin); } else if (toPs->fLeanAngle >= 0) { AngleVectors(toPs->viewangles, toRight, NULL, NULL); VectorMA(toPs->vEyePos, 30, toRight, toOrigin); } else { AngleVectors(toPs->viewangles, toRight, NULL, NULL); VectorMA(toPs->vEyePos, -30, toRight, toOrigin); } VectorCopy(toPs->vEyePos, toOrigin); VectorSubtract(toOrigin, fromOrigin, dir); VectorNormalize(dir); dot = DotProduct(forward, dir); if (SV_ClientIsVisibleTrace(fromOrigin, toOrigin, toPs->viewheight / 2, dot)) { fromClient->lastVisCheckTime[toNum] = svs.time + sv_netoptimize_vistime->integer; return qtrue; } speed = VectorLength(toPs->velocity); if (speed <= 0) { return qfalse; } // // check with velocity prediction // VectorMA(fromOrigin, sv.frameTime * 3, fromPs->velocity, fromOrigin); VectorMA(toOrigin, sv.frameTime * 3, toPs->velocity, toOrigin); if (SV_ClientIsVisibleTrace(fromOrigin, toOrigin, toPs->viewheight / 2, dot)) { fromClient->lastVisCheckTime[toNum] = svs.time + sv_netoptimize_vistime->integer; return qtrue; } // not visible return qfalse;}- L445–L451 Off unless both
g_netoptimizeandsv_netoptimizeare non-zero; and the target must be a player slot, anything else is "visible". - L453–L457 Mode 1 (
NETO_CULLED): a target already classifiedCULL_INbyEntityDistCheckis disclosed without any trace, and the grace timer is refreshed. - L459–L461 Grace: while
lastVisCheckTime[toNum]is in the future the target is disclosed with no trace at all. The timer is set tosvs.time + sv_netoptimize_vistime(200 ms by default) on every success below. - L466–L484 Lean offsets of ±30 units are computed for both players and then immediately overwritten by the plain
VectorCopyofvEyePoson L473 and L484 (fixture F1). As compiled, both endpoints are the un-leaned eye positions. - L486–L493
dotis the cosine between the viewer's forward vector and the direction to the target, computed from present positions; the present-position trace (Listing 6) succeeds → disclose and refresh grace. - L495–L498 If the target is not moving there is no second chance: hidden. Only the target's speed is tested (fixture F4).
- L503–L504 Both endpoints are moved by
3 · sv.frameTime× their own velocity: 0.15 s of straight-line motion atsv_fps 20. - L506–L509 The projected trace reuses the present-position
dot(fixture F2). Success → disclose and refresh grace. - L511–L512 Otherwise the player is withheld from this snapshot.
Shows: every condition under which a hidden player is disclosed: mode, near-field bypass, grace, present line of sight, or line of sight 150 ms ahead along both velocity vectors. Does not show: that the projected trace ever "leads" the legitimate client by a useful margin. The horizon is fixed in server time; the client's delay is not (§5.9).
Read as a timing rule, the function says: a hidden player is released at the first server frame in which either (a) a straight line exists between the two eye positions now, or (b) the target is moving and a straight line exists between where both players would be 150 ms from now if each kept its current velocity. Everything else is memory of a previous success (the grace timer) or a distance shortcut (mode 1). The consequences for \(H\) are worked out in section 5.9.
5.6 What "line of sight" means to the server
Both traces call the same helper, which is worth reading because "visible" here is a very specific and very cheap thing: a ray, then possibly a second ray, against the world and against brush entities, with a content mask that excludes players.
qboolean SV_ClientIsVisibleTrace(const vec3_t fromOrigin, const vec3_t toOrigin, float height, float dot) { vec3_t dir; vec3_t end; VectorSubtract(toOrigin, fromOrigin, dir); VectorNormalize(dir); //VectorMA(toOrigin, -height, dir, end); VectorCopy(toOrigin, end); if (SV_WorldTrace(fromOrigin, end, (CONTENTS_SLIME | CONTENTS_LAVA | CONTENTS_SOLID))) { return qtrue; } if (dot < 0) { return qfalse; } end[2] -= height; return SV_WorldTrace(fromOrigin, end, (CONTENTS_SLIME | CONTENTS_LAVA | CONTENTS_SOLID));}qboolean SV_WorldTrace(const vec3_t start, const vec3_t end, int mask){ trace_t trace = { 0 }; CM_BoxTrace(&trace, start, end, vec3_origin, vec3_origin, 0, mask, qfalse); if (trace.fraction != 1) { return qfalse; } // Also test against brush models return SV_ClipMoveToBSPEntities(start, end, mask);}- L414–L417
diris computed and normalised, but the only line that would use it is commented out;endis simply the target's eye position (fixture F3). - L419–L421 First trace, eye to eye, against
CONTENTS_SOLID | CONTENTS_SLIME | CONTENTS_LAVA. Other players, glass that is not solid, smoke, and foliage are not in that mask, so they never occlude. - L423–L428 If the viewer is looking away (
dot < 0) the second trace is skipped; otherwise the endpoint is lowered byheight(half the target's view height) and traced again.dotdecides whether the second trace runs; it never decides visibility by itself. - L396–L402
SV_WorldTraceis a zero-extent box trace through the world BSP followed bySV_ClipMoveToBSPEntities, which clips againstSOLID_BSPentities (doors, movers) using the same mask.
Shows: what "line of sight" means to the server: two ray tests from the viewer's eye against world geometry and brush entities. Does not show: anything about volumetric occluders, foliage, or player models; and nothing about the client's field of view or resolution.
Two players standing in a doorway, one behind the other, do not occlude each other for
this test; a closed door does, both through the areaportal gate of section 5.3 and,
if it is a brush model, through the second clip. The test has no notion of the viewer's
screen: a target that is behind the viewer passes the eye-to-eye trace as long as the
ray is clear, because dot only decides whether the lowered second ray is
attempted. The far-plane classifier of section 5.4 is the only place where looking away
costs an entity anything, and there it only makes the far cut stricter.
5.7 The grace timer
Every success writes svs.time + sv_netoptimize_vistime into a per-viewer,
per-target array (server.h:227), and the next
sv_netoptimize_vistime milliseconds of checks for that pair return "visible"
without tracing. With the 200 ms default this means the server keeps sending a player for
four frames after they were last seen, which covers a peeker stepping back behind cover
and re-emerging. In timing terms the grace timer changes nothing about the
first reveal, which is what \(H\) measures; it changes how often a first reveal
happens, because a target that ducks in and out within 200 ms never becomes hidden again.
It also has a second-order effect on the cheat: a target that was seen once is disclosed
for 200 ms after cover is regained, so the cheat window at the end of an encounter
is at least 200 ms plus \(D_A\). The model of section 9 treats the grace period as an
outcome to measure, not as a parameter to tune, because its value is a matter of feel.
5.8 Sounds for the unseen
The documentation's remark that "sounds like footsteps will be sent" is implemented as a small queue per entity and a forwarder in the snapshot builder. The interesting property is where the sound is placed.
static void SV_AddNonPVSSound(client_t* client, gentity_t* ent) { int i; for (i = 0; i < ent->r.num_nonpvs_sounds; i++) { nonpvs_sound_t *npvs = &ent->r.nonpvs_sounds[i]; SV_ClientSound( client, &ent->s.origin, ENTITYNUM_NONE, 1, npvs->index, npvs->volume, npvs->minDist, npvs->pitch, npvs->maxDist, qfalse ); }}void Entity::PlayNonPvsSound(const str& soundName, float volume){ AliasListNode_t* ret; str name; if (g_gametype->integer == GT_SINGLE_PLAYER) { // Ignore in single-player return; } if (!sv_netoptimize->integer) { // don't use sound indexes for nothing return; } if (edict->r.svFlags & SVF_BOT) { // bots are not real clients return; } if (edict->r.num_nonpvs_sounds >= MAX_NONPVS_SOUNDS) { return; } name = GetRandomAlias(soundName, &ret); if (name.length() && ret) { nonpvs_sound_t* npvs = &edict->r.nonpvs_sounds[edict->r.num_nonpvs_sounds]; npvs->index = gi.pvssoundindex(name.c_str(), ret->streamed); npvs->volume = G_Random() * ret->volumeMod + ret->volume * volume; npvs->minDist = ret->dist; npvs->maxDist = ret->maxDist; npvs->pitch = G_Random() * ret->pitchMod + ret->pitch; edict->r.num_nonpvs_sounds++;- sv_snapshot.c L316–L327 Each queued sound is forwarded as a normal server sound at the hidden player's true
s.origin, attached to no entity (ENTITYNUM_NONE). The client hears a footstep from the right place while the player who made it is absent from the snapshot (fixture F10). - entity.cpp L6443–L6460 Sounds enter the queue only in multiplayer, only when
sv_netoptimizeis on, never for bots, and only while fewer thanMAX_NONPVS_SOUNDS(4) are pending. - L6462–L6471 The queued record carries the sound index, volume, min/max distance and pitch; the queue is cleared once per server frame by
SV_HandleNonPVSSound(Listing 1, L1164).
Shows: that the filter is a partial disclosure, by design: position is withheld from the entity stream and re-disclosed, coarsely and intermittently, through audio. Does not show: how localisable those sounds are to a listener, or how much a cheat that watches the sound stream can recover.
So the filter is a partial-information channel rather than a wall. A hidden player who walks sends up to four positioned sounds per server frame at their true origin, tagged to no entity. A client that listens for footsteps hears them from the right direction, which is the intended effect; a program that reads the sound stream learns a sequence of exact positions at footstep rate, which is a side effect. Box D8 in section 9 tries to put a number on it. I want to be careful here: this is not a flaw in the implementation so much as a statement of what the feature is for. It is meant to stop the client from rendering hidden players, and it does that; it is not meant to be an information boundary, and it is not one.
5.9 What three server frames are, and are not
The projected trace is often described as "the server looks 150 ms ahead so the client has the data in time". The code supports a narrower statement. The server tests whether a straight line exists between two straight-line extrapolations of the players' positions 150 ms ahead; if the test passes at frame \(t_j\), the player is included in the snapshot built at \(t_j\). Whether that is 150 ms "ahead" of the true reveal depends on the path the players actually take, on the frame quantisation, and on whether the target is moving at all. The box below does the arithmetic; the paragraph after it gives the short version.
Level 3The disclosure lead as \(H = k f + \rho\): a prediction horizon, not a guaranteed look-ahead
Question: given the projected trace with horizon \(k f\) (\(k = 3\), \(f = 50\) ms), what values can the disclosure lead \(H = V - S\) take, and under what conditions?
Let the true line-of-sight opening time be \(V\), and suppose that once open, the line stays open for the crossing (the usual corner). Let \(\hat V_j\) be the opening time that the straight-line projection from frame \(t_j\) would predict. The projected trace at frame \(t_j\) succeeds iff \(\hat V_j \le t_j + k f\). Under straight constant-velocity motion the projection is exact, \(\hat V_j = V\), so the first successful frame is
$$ S = \min\{\, t_j : t_j \ge V - k f \,\} = f \left\lceil \frac{V - k f}{f} \right\rceil, \tag{10} $$and therefore
$$ H = V - S = k f + \rho, \qquad \rho = V - k f - f\left\lceil \tfrac{V - k f}{f}\right\rceil \in (-f, 0]. \tag{11} $$With \(k = 3\) and \(f = 50\) ms: \(H \in (100, 150]\) ms, uniformly distributed over the interval if \(V\) is uniformly distributed relative to the frame grid. So even in the best case the lead is not 150 ms; it is 150 ms minus a frame phase.
If the players do not move in straight lines, the projection can be wrong in either direction. A peeker who strafes out and back, or who rounds a curved corner, has \(\hat V_j > V\) for early \(j\); the projection then never succeeds before the present trace does, and disclosure falls back to the present-trace rule with \(H = V - f\lceil V/f \rceil \in (-f, 0]\): the player is released only at the first frame after the line opened, which is an entire snapshot period late in the worst case. The projection can also succeed spuriously (a projected line through a gap the real path never crosses), giving \(H\) larger than \(k f\). Overall
$$\begin{gathered} H \in (-f,\; k f\,] \quad\text{in the monotone case},\\ H \text{ unbounded above in general, but only through mis-prediction.} \end{gathered} \tag{12} $$The speed gate. The projection branch runs only if the target has non-zero speed (Listing 5, L495–L498), although both endpoints are projected. Consider the usual corner: a holder standing still and a peeker running out. In the snapshot built for the holder, the target is the moving peeker, so the projection runs and the holder receives the peeker with \(H \in (100, 150]\) ms. In the snapshot built for the peeker, the target is the stationary holder, the branch returns false before projecting, the peeker's own velocity is never used, and the holder is released only when the present trace passes: \(H \in (-f, 0]\). The two players' snapshots are therefore governed by different rules in the same encounter, and the one who is moving, who already has the geometric initiative, is the one who receives the target late. I do not know whether this asymmetry is intended (it is what the code does; it is also what a bandwidth optimisation that only worries about "moving things appearing" would naturally produce). It is fixture F4.
Worked numbers. Take \(D_A = 60\) ms and \(B \approx 60\) ms (section 6.1 explains why \(B\) is about one snapshot period at the default rate), so \(D_R \approx 120\) ms. For the holder, \(H \in (100, 150]\): the cheat's window is \(W = [H - D_A]_+ \in (40, 90]\) ms and lateness \(L = [D_R - H]_+ \in [0, 20)\) ms. For the peeker, \(H \in (-50, 0]\): \(W = 0\) and \(L \in [120, 170)\) ms. If the holder is on a slower link, \(D_A = 120\) ms, \(D_R \approx 180\) ms, their window shrinks to \([0, 30]\) ms and their lateness grows to \([30, 80)\) ms. None of these numbers is a property of the horizon alone; each is a property of the horizon and the client's delays, which is why the horizon has to be chosen against a distribution (section 9).
The short version: three frames is a prediction horizon in server time. It converts into a lead of between two and three frames when the prediction is right, into a lag of up to one frame when the prediction is structurally unavailable (the stationary target), and into something else when the path curves. It is never a promise about the client, because the client's delay \(D_A + B\) is not an input to the rule. Valorant's design note on the same problem states the rule the other way round: the look-ahead is chosen to exceed the viewer's expected ping [6]. That is a per-client horizon, and the next box says what it would take here.
Level 3A per-client horizon: the horizon as a function of the viewer's delay
Question: if the server could vary \(k\) per viewer, what should it be a function of, and what would the fixture for such a change look like?
Section 4's optimum \(h^*\) depends on the delay distribution of the viewer
receiving the snapshot. The server has an estimate of each client's round-trip time
(it computes ping from acknowledged frames, cl_parse.cpp:380–387
on the client side, and the server keeps client->ping), so it can
condition on it. The natural rule is
that is, choose per viewer \(i\) a horizon that covers an upper quantile \(\alpha\) of that viewer's delivery delay plus a nominal render delay, and round up to frames. With \(\alpha\) set by the cost ratio of D2, this is the balance condition of box D2 applied one client at a time. Each client then gets the smallest lead that keeps their lateness under control, and a low-latency client stops paying for a high-latency client's \(k\). The expected cost is never worse than with a single \(k\) (a single \(k\) is a feasible per-client rule) and is strictly better whenever delays differ.
Two caveats. The viewer's ping is a noisy, lagged estimate of \(D_A\), so the rule should use a smoothed upper quantile, not the last sample; and a client can inflate its apparent ping to buy a larger \(k\), which is a new cheat, so \(k_i\) needs a cap. The fixture is simple: two viewers with scripted pings of 20 and 120 ms watching the same scripted peek must receive the peeker in snapshots whose times differ by the frame-rounded difference in horizons, and neither may receive it earlier than the capped horizon allows.
5.10 To the wire, and the knobs
After the entity set is decided, the snapshot is serialised. The timing-relevant fields are the timestamp and, surprisingly, a residual byte that OpenMoHAA adds and that no client reads.
MSG_WriteSVC(msg, svc_snapshot); // NOTE, MRE: now sent at the start of every message from server to client // let the client know which reliable clientCommands we have received //MSG_WriteLong( msg, client->lastClientCommand ); // send over the current server time so the client can drift // its view of time to try to match if( client->oldServerTime ) { // The server has not yet got an acknowledgement of the // new gamestate from this client, so continue to send it // a time as if the server has not restarted. Note from // the client's perspective this time is strictly speaking // incorrect, but since it'll be busy loading a map at // the time it doesn't really matter. MSG_WriteLong (msg, svs.time + client->oldServerTime); } else { // WOMBAT: note that MOHAA always goes into this else. // therefore we are deviating from the MOHAA protocol but i don't think this is a problem MSG_WriteLong (msg, svs.time); } if ( sv.timeResidual > 254 ) MSG_WriteByte( msg, 255 ); else MSG_WriteByte( msg, sv.timeResidual ); } // delta encode the playerstate if ( oldframe ) { MSG_WriteDeltaPlayerstate( msg, &oldframe->ps, &frame->ps, sv.frameTime); } else { MSG_WriteDeltaPlayerstate( msg, NULL, &frame->ps, sv.frameTime); } // delta encode the entities SV_EmitPacketEntities (oldframe, frame, msg); MSG_WriteSounds( msg, client->server_sounds, client->number_of_server_sounds );- L159, L178 The snapshot carries
svs.time, the integer frame time from Listing 1. The client's clock (§6.1) is built on it. - L181–L183 OpenMoHAA also writes one byte with the leftover
sv.timeResidual(capped at 255). The client reads it intoserverTimeResidualand never uses it (fixture F14). - L207–L212 The player state is delta-encoded against the last acknowledged frame, or in full if none is usable;
sv.frameTime(seconds) is passed through to suppress predictable animation-time deltas. - L215, L217 Entities are emitted by
SV_EmitPacketEntitiesas deltas against the old frame; entities that were in the old frame but not this one are removed on the client. Then the frame's sounds, including the non-PVS ones from Listing 7.
Shows: what a snapshot is on the wire: a time, a player state and the delta of a per-client entity set, followed by sounds. Does not show: packet size, Huffman coding details, or reliable command handling, which are inherited and unchanged in shape.
The client parser reads the byte right after the time and stores it in a field nothing else touches:
newSnap.serverTime = MSG_ReadLong( msg ); newSnap.serverTimeResidual = MSG_ReadByte( msg );
cl_parse.cpp:290–291 (fixture F14). It is one byte per snapshot; it would be a cheap sub-frame timestamp if the clock code of section 6.1 used it.
When a snapshot is sent is decided by SV_SendClientMessages
(sv_snapshot.c:1277–1325): a client is skipped while less than its
snapshotMsec has elapsed since its last snapshot, and non-LAN clients are
further rate-limited by SV_RateMsec. snapshotMsec comes from the
client's snaps userinfo value, clamped to the server's frame rate:
{ i = atoi(val); if(i < 1) i = 1; else if(i > sv_fps->integer) i = sv_fps->integer; i = 1000 / i; } else i = 50; if(i != cl->snapshotMsec) { // Reset last sent snapshot so we avoid desync between server frame time and snapshot send time cl->lastSnapshotTime = 0; cl->snapshotMsec = i; }
sv_client.c:1590–1608. A client asking for more than sv_fps snapshots per second gets exactly sv_fps; one asking for fewer gets fewer. The snapshot period on the client side, and therefore \(B\), is bounded below by the server frame and can be lengthened by the client.
The one place where sv.frameTime reaches the wire is the animation-time delta
suppression, an OpenMoHAA fix that skips sending a value the client can predict:
void MSG_WritePackedAnimTime_ver_15(msg_t* msg, float fromValue, float toValue, float frameTime, int bits){ int packed; // Fixed in OPM // See the comment above the ANIMTIME_DELTA_SUPPRESSION_EPSILON variable if (fabs(toValue - fromValue - frameTime) < ANIMTIME_DELTA_SUPPRESSION_EPSILON) { // Suppress the delta as the next frame is predictable MSG_WriteBits(msg, 0, 1); return;
msg.cpp:2615–2624. Not about visibility; included so that the two uses of sv.frameTime are both on the page.
Finally, the settings, with their defaults at this revision:
sv_fps = Cvar_Get ("sv_fps", "20", CVAR_SAVEGAME | CVAR_SERVERINFO | CVAR_NORESTART ); sv_netoptimize = Cvar_Get("sv_netoptimize", "0", 0); sv_netoptimize_vistime = Cvar_Get("sv_netoptimize_vistime", "200", 0); g_netoptimize = Cvar_Get("g_netoptimize", "1", 0); // Added in 2.30 Cvar_Set("g_netoptimize", "1");typedef enum { NETO_NONE, NETO_CULLED, NETO_ALWAYS} netoptimize_e; g_smoothClients = gi.Cvar_Get("g_smoothClients", "1", 0); sv_netoptimize = gi.Cvar_Get("sv_netoptimize", "0", 0);- sv_init.c L1097
sv_fpsdefaults to 20; it is server-info visible and survives restarts. - L1117–L1119
sv_netoptimizedefaults to 0 (off); the grace windowsv_netoptimize_vistimeto 200 ms;g_netoptimizeto 1. - L610–L611 At map spawn
g_netoptimizeis force-set to 1 ("Added in 2.30"), so in practice onlysv_netoptimizedecides. - server.h L255–L259 The modes: 0 none, 1
NETO_CULLED(skip the trace for near players), 2NETO_ALWAYS. - gamecvars.cpp L511, L673 The game module registers its own handles:
g_smoothClientsdefaults to 1 (this is what makes the server write player velocities, Listing 13), and a mirror ofsv_netoptimizeused by the sound queue.
Shows: the defaults at this revision. A server that never sets sv_netoptimize discloses every player in the PVS. Does not show: what values public servers actually run; that is a survey question, not a source question.
6. What the client does with a late player Level 2
The server decides \(S\). The network decides \(D_A\). Everything from \(A\) to \(R\) is the client's doing, and the client's code is where most of the intuition about "the client has the data early, so it can smooth the reveal" turns out to be only partly true. This section follows a disclosing snapshot from the socket to the screen.
6.1 The client's clock has no fixed delay
Source engines have an explicit interpolation delay (cl_interp, by default
two snapshot periods) [8]. Quake III and its descendants do not. The client
keeps an offset between its wall clock and server time, and adjusts that offset by a
millisecond or two on every snapshot, pushing forward when it did not run out of data
and pulling back when it did. The render delay \(B\) is whatever that feedback loop
settles on.
// cl_timeNudge is a user adjustable cvar that allows more // or less latency to be added in the interest of better // smoothness or better responsiveness. int tn; tn = cl_timeNudge->integer; if (tn<-30) { tn = -30; } else if (tn>30) { tn = 30; } cl.serverTime = cls.realtime + cl.serverTimeDelta - tn; // guarantee that time will never flow backwards, even if // serverTimeDelta made an adjustment or cl_timeNudge was changed if ( cl.serverTime < cl.oldServerTime ) { cl.serverTime = cl.oldServerTime; } // note if we are almost past the latest frame (without timeNudge), // so we will try and adjust back a bit when the next snapshot arrives if ( cls.realtime + cl.serverTimeDelta >= cl.snap.serverTime - 5 ) { cl.extrapolatedSnapshot = qtrue; } }#define RESET_TIME 500void CL_AdjustTimeDelta( void ) { int resetTime; int newDelta; int deltaDelta; cl.newSnapshots = qfalse; // the delta never drifts when replaying a demo if ( clc.demoplaying ) { return; } // if the current time is WAY off, just correct to the current value if ( com_sv_running->integer ) { resetTime = 100; } else { resetTime = RESET_TIME; } newDelta = cl.snap.serverTime - cls.realtime; deltaDelta = abs( newDelta - cl.serverTimeDelta ); if ( deltaDelta > RESET_TIME ) { cl.serverTimeDelta = newDelta; cl.oldServerTime = cl.snap.serverTime; // FIXME: is this a problem for cgame? cl.serverTime = cl.snap.serverTime; if ( cl_showTimeDelta->integer ) { Com_Printf( "<RESET> " ); } } else if ( deltaDelta > 100 ) { // fast adjust, cut the difference in half if ( cl_showTimeDelta->integer ) { Com_Printf( "<FAST> " ); } cl.serverTimeDelta = ( cl.serverTimeDelta + newDelta ) >> 1; } else { // slow drift adjust, only move 1 or 2 msec // if any of the frames between this and the previous snapshot // had to be extrapolated, nudge our sense of time back a little // the granularity of +1 / -2 is too high for timescale modified frametimes if ( !cls.timeScaled ) { if ( cl.extrapolatedSnapshot ) { cl.extrapolatedSnapshot = qfalse; cl.serverTimeDelta -= 2; } else { // otherwise, move our sense of time forward to minimize total latency cl.serverTimeDelta++; } } } if ( cl_showTimeDelta->integer ) { Com_Printf( "%i ", cl.serverTimeDelta ); }}- L1254–L1261 The displayed time is real time plus an offset, minus
cl_timeNudge(clamped to ±30 ms, default 0). There is no "interp delay" constant anywhere in this path. - L1265–L1267 Time never runs backwards;
cl.oldServerTimeis refreshed after each rendered frame inCL_CGameRendering(L1010). - L1271–L1273 If the client is within 5 ms of the newest snapshot it flags that it had to extrapolate.
- L1068–L1071 On every new snapshot the offset that would put the client exactly on the newest snapshot is compared to the current one; a jump above
RESET_TIME(500 ms) resets outright. The localresetTimeat L1063–L1065 is computed and never used (fixture F13). - L1078–L1083 A gap above 100 ms is halved; otherwise the offset drifts by −2 ms after an extrapolated frame or +1 ms if not (L1091–L1096).
Shows: the render delay \(B\) is a random variable produced by this feedback loop: the client rides a few milliseconds behind the newest snapshot, nudged by jitter. Does not show: what \(B\) looks like on a real link; that is a measurement, and §10 says what to log.
The loop has a steady state, and it is worth deriving because it is the value of \(B\)
for the default configuration. Call \(P\) the snapshot period (50 ms) and \(\ell\) the
amount by which the displayed time trails the newest snapshot's time at the moment that
snapshot arrives. Between arrivals the displayed time advances \(P\), so just before the
next snapshot it trails by \(\ell - P\); the client flags an extrapolated frame when that
trail is 5 ms or less. If it was flagged, the next adjustment is −2 ms (\(\ell\) grows by
2); if not, +1 ms (\(\ell\) shrinks by 1). So \(\ell\) shrinks one millisecond per
snapshot until it reaches \(P + 5\), gets pushed back to \(P + 7\), and shrinks again: a
three-snapshot cycle around \(\ell \approx P + 5\), tripping the flag one snapshot in
three. In server-time terms the client renders about one snapshot period behind the
newest snapshot it holds, plus 5 ms, plus the time to the next rendered frame. That is
the nominal \(B\): roughly 55 to 70 ms at 20 snapshots per second, larger at lower
snapshot rates, and it is emergent rather than configured. cl_timeNudge shifts
it by up to ±30 ms; jitter shifts it by whatever the halving rule at L1078 leaves
behind; and after a reset (a 500 ms jump, for example at connect) the client starts with
no buffer at all and walks back to the steady state at 2 ms per snapshot, which takes
about a second and a half at 20 Hz.
Figure 4 · A disclosing snapshot on the client's timeline
Wall-clock time on the client, with the disclosing snapshot arriving at zero. The client's displayed time trails the newest snapshot by roughly one period plus 5 ms, so the newly disclosed player sits in memory, readable but undrawn, until the display reaches that snapshot's time. Delay the following snapshot to see the extrapolation branch and its cap.
Figure 4. Steady state of Listing 10 with the rules of Listing 11, Listing 14 and Listing 13. The teal band is the interpolating branch; the plum band is the extrapolating branch that runs when the following snapshot has not arrived; grey is the frozen state past the cap. The rust band is the cheat's head start on the legitimate renderer, before the server's own lead is counted.
6.2 Snapshot transitions and who may interpolate
The client holds at most two snapshots for rendering: cg.snap, whose time is
at or before the displayed time, and cg.nextSnap, whose time is after it.
CG_ProcessSnapshots (cg_snapshot.c:410–491) reads new
snapshots as needed and transitions when the displayed time passes the next one's time.
When a snapshot becomes nextSnap, every entity in it is examined and marked
eligible or ineligible for interpolation. This is the rule that decides what a first
reveal looks like.
static void CG_SetNextSnap(snapshot_t *snap){ int num; entityState_t *es; centity_t *cent; cg.nextSnap = snap; cg_entities[cg.snap->ps.clientNum].interpolate = qtrue; // check for extrapolation errors for (num = 0; num < snap->numEntities; num++) { es = &snap->entities[num]; cent = &cg_entities[es->number]; cent->nextState = *es; // if this frame is a teleport, or the entity wasn't in the // previous frame, don't interpolate if (!cent->currentValid || ((cent->currentState.eFlags ^ es->eFlags) & EF_TELEPORT_BIT) || (cent->currentState.parent != es->parent) || (cent->currentState.modelindex != es->modelindex)) { cent->interpolate = qfalse; // if this isn't the first frame and we have valid data, set the // teleport flag if (cent->currentValid) { cent->teleported = qtrue; } } else { cent->interpolate = qtrue; } } // if the next frame is a teleport for the playerstate, we // can't interpolate during demos if (cg.snap && (snap->ps.pm_flags & PMF_RESPAWNED)) { cg.nextFrameTeleport = qtrue; } else { cg.nextFrameTeleport = qfalse; } // if changing follow mode, don't interpolate if (cg.nextSnap->ps.clientNum != cg.snap->ps.clientNum) { cg.nextFrameTeleport = qtrue; } // if the camera cut bit changed, than the next frame is a camera cut if ((cg.nextSnap->ps.camera_flags & CF_CAMERA_CUT_BIT) != (cg.snap->ps.camera_flags & CF_CAMERA_CUT_BIT)) { cg.nextFrameCameraCut = qtrue; } else { cg.nextFrameCameraCut = qfalse; } // if changing server restarts, don't interpolate if (snap->serverTime < cgi.GetServerStartTime()) { // reset the camera cg.nextFrameTeleport = qtrue; } // sort out solid entities CG_BuildSolidList();}- L280 The local player entity is marked interpolatable up front; the loop below may overwrite it.
- L289–L301 Four disqualifiers: the entity was not in the previous frame (
!currentValid), its teleport bit flipped, its parent changed, or its model index changed. Any of these forbids interpolation for this transition and, if the entity was previously valid, marks itteleported. The last two tests are MOHAA additions (§7). - L306–L310 A respawn (
PMF_RESPAWNED) on the local player state forbids interpolating the view; ioquake3 uses the teleport bit for this. - L313–L328 Follow-mode changes, camera cuts and server restarts are further whole-frame discontinuities.
Shows: that a player appearing for the first time after being withheld is never interpolated into place: the first snapshot that contains them has no previous state to interpolate from. Does not show: what the renderer does instead; that is Listing 14.
There is one more fact needed to read the timeline correctly. Entities are drawn from
cg.snap, not from cg.nextSnap: CG_AddPacketEntities
iterates cg.snap->entities (cg_ents.c:641–644). An
entity present only in the next snapshot has its nextState filled in, which
any process reading the client's memory can see, but no model on screen, until the
transition. That transition is where the eligibility flag is consumed.
static void CG_TransitionEntity(centity_t *cent){ cent->currentState = cent->nextState; cent->currentValid = qtrue; // reset if the entity wasn't in the last frame or was teleported if (!cent->interpolate) { CG_ResetEntity(cent); } // clear the next state. if will be set by the next CG_SetNextSnap cent->interpolate = qfalse; // reset the teleported state cent->teleported = qfalse; if (cent->currentState.eType == ET_EVENTS) { CG_Event(cent); }}static void CG_ResetEntity(centity_t *cent){ dtiki_t *tiki; int i; VectorCopy(cent->currentState.origin, cent->lerpOrigin); VectorCopy(cent->currentState.angles, cent->lerpAngles); // if we just teleported, all we care about is the position and orientation // information if (cent->teleported) { cent->teleported = qfalse; return; }- L115–L116 The pending state becomes the current state.
- L119–L121 If the entity was not eligible to interpolate,
CG_ResetEntitysnapslerpOriginandlerpAnglesto the new state (L44–L46) and, if the entity was flagged as teleported, stops there; otherwise the rest of the function (not shown) also resets animation state. - L124, L126 Eligibility and the teleport flag are cleared; the next
CG_SetNextSnapdecides again.
Shows: the first rendered position of a newly disclosed player is the origin from the disclosing snapshot, with no smoothing from wherever they "were". Does not show: the animation consequences of the reset (see the history note).
History: the animation reset in CG_ResetEntity was reworked in 78b2637c (2024-10-20); the eligibility rule in Listing 11 has been stable since the 2023 reformatting.
6.3 Four rendering branches and a 100 ms cap
MOHAA stripped Quake III's typed trajectories down to a velocity and a timestamp, and
the client's evaluator became a straight line with a cap. The server writes the velocity
for players only when its own g_smoothClients is on.
void BG_EvaluateTrajectory(const trajectory_t *tr, int atTime, const vec3_t base, vec3_t result){ float deltaTime; if (atTime > cg_smoothClientsTime->integer + tr->trTime) { atTime = cg_smoothClientsTime->integer + tr->trTime; } deltaTime = (float)(atTime - tr->trTime) / 1000.0; result[0] = tr->trDelta[0] * deltaTime + base[0]; result[1] = tr->trDelta[1] * deltaTime + base[1]; result[2] = tr->trDelta[2] * deltaTime + base[2];}typedef struct { int trTime; vec3_t trDelta;} trajectory_t; if (g_gametype->integer != GT_SINGLE_PLAYER && g_smoothClients->integer) { VectorCopy(client->ps.velocity, edict->s.pos.trDelta); edict->s.pos.trTime = client->ps.commandTime; } else { VectorClear(edict->s.pos.trDelta); edict->s.pos.trTime = 0; }- cg_ents.c L432–L434 The evaluation time is clamped to
trTime + cg_smoothClientsTime(100 ms by default, a client-archived cvar). Past that, the entity is frozen at the 100 ms projection. - L436–L440 Then it is a straight line:
base + trDelta · Δt. No trajectory types, no gravity, no sine movers in this path. - q_shared.h L1979–L1982 The wire struct has only a time and a delta; compare ioquake3's five-field
trajectory_tin §7. - player.cpp L7169–L7175 On the server, a player's
trDeltais their velocity andtrTimetheircommandTime, but only in multiplayer and only whileg_smoothClientsis on; otherwise both are zeroed and the client's extrapolation branch has nothing to extrapolate with.
Shows: that a newly disclosed moving player can be drawn up to 100 ms ahead of the disclosed origin, using the server-written velocity. Does not show: what a given server's players see: cg_smoothClients and cg_smoothClientsTime are client-side and archived per player.
With those pieces, the position of every entity for the frame is chosen by one function
with four branches. Which branch a newly disclosed player takes depends on the
eligibility flag from section 6.2 and on the viewer's cg_smoothClients.
void CG_CalcEntityLerpPositions(centity_t *cent){ int i; float f; f = cg.frameInterpolation; if (cent->currentState.eType == ET_PLAYER) { if (cent->currentState.number == cg.snap->ps.clientNum) { // if the player, take position from prediction VectorCopy(cg.predicted_player_state.origin, cent->lerpOrigin); for (i = 0; i < 3; i++) { cent->lerpAngles[i] = LerpAngle(cent->currentState.angles[i], cent->nextState.angles[i], f); } return; } } if (cent->currentState.eType != ET_PLAYER || !cg_smoothClients->integer) { float quat[4]; float mat[3][3]; if (!cent->interpolate) { VectorCopy(cent->currentState.angles, cent->lerpAngles); VectorCopy(cent->currentState.origin, cent->lerpOrigin); return; } for (i = 0; i < 3; i++) { cent->lerpOrigin[i] = cent->currentState.origin[i] + f * (cent->nextState.origin[i] - cent->currentState.origin[i]); } if (!memcmp(cent->currentState.angles, cent->nextState.angles, sizeof(vec3_t))) { VectorCopy(cent->currentState.angles, cent->lerpAngles); } else { // use spherical interpolation using quaternions so that bound objects // rotate properly without gimble lock. SlerpQuaternion(cent->currentState.quat, cent->nextState.quat, f, quat); QuatToMat(quat, mat); MatrixToEulerAngles(mat, cent->lerpAngles); } } else if (cent->interpolate) { float quat[4]; float mat[3][3]; // if the entity has a valid next state, interpolate a value between the frames // unless it is a mover with a known start and stop vec3_t current, next; // this will linearize a sine or parabolic curve, but it is important // to not extrapolate player positions if more recent data is available BG_EvaluateTrajectory(¢->currentState.pos, cg.snap->serverTime, cent->currentState.origin, current); BG_EvaluateTrajectory(¢->nextState.pos, cg.nextSnap->serverTime, cent->nextState.origin, next); cent->lerpOrigin[0] = current[0] + f * (next[0] - current[0]); cent->lerpOrigin[1] = current[1] + f * (next[1] - current[1]); cent->lerpOrigin[2] = current[2] + f * (next[2] - current[2]); if (!memcmp(cent->currentState.angles, cent->nextState.angles, sizeof(vec3_t))) { VectorCopy(cent->currentState.angles, cent->lerpAngles); } else { // use spherical interpolation using quaternions so that bound objects // rotate properly without gimble lock. SlerpQuaternion(cent->currentState.quat, cent->nextState.quat, f, quat); QuatToMat(quat, mat); MatrixToEulerAngles(mat, cent->lerpAngles); } } else { // just use the current frame and evaluate as best we can BG_EvaluateTrajectory(¢->currentState.pos, cg.time, cent->currentState.origin, cent->lerpOrigin); VectorCopy(cent->currentState.angles, cent->lerpAngles); }}- L456–L465 The local player comes from prediction (§6.5); only angles are lerped.
- L468–L491 Non-players, or players when
cg_smoothClientsis off: no eligibility → copy the current origin (L472–L476); eligible → straight lerp of raw origins byfand quaternion slerp of angles. - L492–L517 Players with
cg_smoothClientson and eligible: each endpoint is first evaluated byBG_EvaluateTrajectoryat its own snapshot's server time, then lerped. SincetrTimeis the player'scommandTime, this shifts both endpoints forward by the gap between that command and the frame time before interpolating. - L518–L522 Players with
cg_smoothClientson and not eligible, which is exactly the first-reveal case: extrapolate the single known state tocg.time, subject to the 100 ms cap in Listing 13.
Shows: the two things a first-reveal frame can look like: a hard snap (branch 2) or a capped extrapolation (branch 4), depending on one client cvar. Does not show: which one a given player sees, or how visible the snap is at their frame rate.
// set cg.frameInterpolation if (cg.nextSnap && r_lerpmodels->integer) { int delta; delta = (cg.nextSnap->serverTime - cg.snap->serverTime); if (delta == 0) { cg.frameInterpolation = 0; } else { cg.frameInterpolation = (float)(cg.time - cg.snap->serverTime) / delta; } } else { cg.frameInterpolation = 0; // actually, it should never be used, because // no entities should be marked as interpolating }- L853 Only with a pending next snapshot and
r_lerpmodelson; otherwisef = 0and the comment admits nobody should be interpolating. - L856–L860 \(f = (t_{\text{cg}} - t_k)/(t_{k+1} - t_k)\): where the display clock sits between the two snapshots the client holds. With a 50 ms snapshot period and the clock from Listing 10, \(f\) sweeps 0 → 1 every 50 ms.
Shows: the convex-combination form assumed in deep box D1. Does not show: that \(f\) is always in \([0,1)\); CG_ProcessSnapshots maintains that invariant separately and errors out if it breaks.
The relevant client cvars and their defaults, for reference (cg_main.c:137–159):
cg_errorDecay = cgi.Cvar_Get("cg_errordecay", "100", 0); cg_nopredict = cgi.Cvar_Get("cg_nopredict", "0", 0); cg_showmiss = cgi.Cvar_Get("cg_showmiss", "0", 0); cg_stats = cgi.Cvar_Get("cg_stats", "0", 0); cg_hidetempmodels = cgi.Cvar_Get("cg_hidetempmodels", "0", 0); cg_synchronousClients = cgi.Cvar_Get("g_synchronousClients", "0", 0); cg_stereoSeparation = cgi.Cvar_Get("cg_stereosep", "0.4", CVAR_ARCHIVE); cg_lagometer = cgi.Cvar_Get("cg_lagometer", "0", 0); paused = cgi.Cvar_Get("paused", "0", 0); r_lerpmodels = cgi.Cvar_Get("r_lerpmodels", "1", 0); cg_3rd_person = cgi.Cvar_Get("cg_3rd_person", "0", CVAR_CHEAT); cg_drawviewmodel = cgi.Cvar_Get("cg_drawviewmodel", "2", CVAR_ARCHIVE); cg_cameraheight = cgi.Cvar_Get("cg_cameraheight", "18", CVAR_ARCHIVE); cg_cameradist = cgi.Cvar_Get("cg_cameradist", "120", CVAR_ARCHIVE); cg_cameraverticaldisplacement = cgi.Cvar_Get("cg_cameraverticaldisplacement", "-2", CVAR_ARCHIVE); cg_camerascale = cgi.Cvar_Get("cg_camerascale", "0.3", CVAR_ARCHIVE); cg_traceinfo = cgi.Cvar_Get("cg_traceinfo", "0", CVAR_ARCHIVE); cg_debugFootsteps = cgi.Cvar_Get("cg_debugfootsteps", "0", CVAR_CHEAT); cg_smoothClients = cgi.Cvar_Get("cg_smoothClients", "1", CVAR_ARCHIVE); cg_smoothClientsTime = cgi.Cvar_Get("cg_smoothClientsTime", "100", CVAR_ARCHIVE); pmove_fixed = cgi.Cvar_Get("pmove_fixed", "0", 0); pmove_msec = cgi.Cvar_Get("pmove_msec", "8", 0); cg_pmove_msec = cgi.Cvar_Get("cg_pmove_msec", "8", 0);
cg_smoothClients and cg_smoothClientsTime are CVAR_ARCHIVE, so they are per-player choices that survive restarts; the server has no say in them. In ioquake3 the equivalent cvar defaults to 0 and is CVAR_USERINFO (cg_main.c:309), so the server at least knows what each client chose.
6.4 A first reveal, frame by frame
Put sections 6.1 to 6.3 together for a player who was withheld and is disclosed in snapshot \(m\), whose server time is \(T_m\). All numbers assume the defaults and the steady state of section 6.1.
- \(A\): snapshot \(m\) arrives. Its entity list, including the new player, is
parsed into
cl.parseEntities. The displayed time is about \(T_m - 55\) ms.cg.snapis snapshot \(m-2\) andcg.nextSnapis \(m-1\); the new player is in neither and is not drawn. The data is in memory. - \(A + 5\) ms: snapshot \(m\) becomes
nextSnap. The displayed time crosses \(T_{m-1}\);CG_TransitionSnapshotmakes \(m-1\) current andCG_SetNextSnapinstalls \(m\). For the new playercurrentValidis false, sointerpolate = qfalse(Listing 11, L291–L293). The entity'snextStatenow holds the disclosed origin and velocity. Still not drawn: the renderer iterates snapshot \(m-1\). - \(A + 55\) ms: snapshot \(m\) becomes current. The displayed time crosses
\(T_m\).
CG_TransitionEntitycopies the state and, because the entity is ineligible,CG_ResetEntityplaceslerpOriginexactly at the disclosed origin (Listing 12). In the same call, because snapshot \(m+1\) arrived about 5 ms ago,CG_SetNextSnap(m+1)runs immediately: the player is now valid, no teleport bit flipped, same parent and model, sointerpolate = qtrue. - \(R\): the first frame drawn after that.
CG_CalcEntityLerpPositionstakes the third branch (Listing 14, L492–L517): each endpoint is advanced from itscommandTimeto its snapshot time along the server-written velocity, then blended with \(f \approx 0\). The player appears at approximately the disclosed position, moving smoothly from the first frame. There is no visual snap, because there was nothing on screen to snap from; the cost was paid entirely as the 55 ms of latency in step 3, on top of \(D_A\).
That is the nominal path. The other path is the one Figure 4 shows when you delay the
following snapshot: if \(m+1\) has not arrived when the displayed time crosses
\(T_m\), cg.nextSnap is null, the interpolation fraction is zero
(Listing 15, L853), the eligibility flag stays false, and the renderer takes
the fourth branch: extrapolate the single known state to the displayed time along the
velocity, but no further than 100 ms past the state's commandTime, after
which the player stands still until data arrives (fixture F8). When \(m+1\) finally
arrives, CG_SetNextSnap marks the player eligible and the third branch takes
over from wherever the extrapolation had reached, with a discontinuity equal to the
extrapolation error. Box D5 bounds that error.
Two things follow for the timing model. First, \(B\) is not "one frame": on the nominal path it is a full snapshot period plus a few milliseconds, because the client will not draw a snapshot until it also holds the one after it, and the disclosing snapshot is by construction the first one to carry the player. Second, the server's 150 ms horizon and the client's 55 ms hold are not coordinated: the horizon buys \(H\), the hold spends \(B\), and neither side reads the other's number.
Level 3How wrong can the capped extrapolation be?
Question: on the late path, how far from the true position can the fourth branch place a player, given the 100 ms cap?
Let the server-written velocity be \(v_0\), sampled at command time \(t_0\), and let the client extrapolate for \(\tau \le \tau_{\max} = 0.1\) s. Write the true motion as \(u(t_0 + \tau) = u_0 + v_0\tau + \Delta v\,\tau + \int_0^\tau\!\!\int_0^s a(r)\,dr\,ds\), where \(u_0\) is the true position at \(t_0\), \(\Delta v\) is any velocity error already present at \(t_0\) (the player state velocity is the value after the last processed command, which may predate the frame) and \(a\) is the acceleration over the interval. The client draws the straight line \(u_0 + v_0\tau\), so its error is
$$ \|u(t_0+\tau) - (u_0 + v_0\tau)\| \;\le\; \|\Delta v\|\,\tau + \tfrac12\,a_{\max}\,\tau^2 . \tag{14} $$For illustration only: at a running speed of about 250 units/s, a player who stops dead at \(t_0\) has \(\|\Delta v\| = 250\) and the extrapolation overshoots by up to 25 units at the cap, a little less than a player's body width; a player who keeps running but turns 90° has \(\|\Delta v\| \approx 350\) and an error up to 35 units; a player who keeps going straight has no error from this term at all. The quadratic term with \(a_{\max}\) of a few thousand units/s\(^2\) adds at most 10 to 15 units at \(\tau = 0.1\). So the discontinuity when the next snapshot arrives is bounded by roughly one body width in the worst case and is zero in the common case of a peeker who keeps moving. Beyond the cap the error grows linearly with the true displacement, which is why the cap exists: a 300 ms outage would otherwise draw a stopped player 75 units through a wall.
6.5 Local prediction and command replay
The viewer's own body follows a different rule. The client does not wait for snapshots to move the local player; it runs the shared movement code on the inputs it has already sent and shows the result now, then reconciles when the server's version arrives. This is the technique Bernier described for Half-Life [7] and that the Gambetta articles walk through with a live demo [9]; OpenMoHAA's version starts from the newest snapshot it holds and replays every command the server has not yet acknowledged.
// Added in OPM: prediction check // Don't use the next snapshot if the prediction is about to be disabled // this prevent "blinking" issues like when entering a vehicle turret gun // where arms would be seen shortly if (cg.nextSnap && !cg.nextFrameTeleport && !cg.thisFrameTeleport && !(cg.nextSnap->ps.pm_flags & PMF_NO_PREDICTION)) { cg.predicted_player_state = cg.nextSnap->ps; cg.physicsTime = cg.nextSnap->serverTime; } else { cg.predicted_player_state = cg.snap->ps; cg.physicsTime = cg.snap->serverTime; } // run cmds moved = qfalse; for (cmdNum = current - CMD_BACKUP + 1; cmdNum <= current; cmdNum++) { // get the command cgi.GetUserCmd(cmdNum, &cg_pmove.cmd); if (cg_pmove.pmove_fixed) { PM_UpdateViewAngles(cg_pmove.ps, &cg_pmove.cmd); } // don't do anything if the time is before the snapshot player time if (cg_pmove.cmd.serverTime <= cg.predicted_player_state.commandTime) { continue; } // don't do anything if the command was from a previous map_restart if (cg_pmove.cmd.serverTime > latestCmd.serverTime) { continue; } if (cg.predicted_player_state.commandTime == oldPlayerState.commandTime) { if (cg.thisFrameTeleport) { // a teleport will not cause an error decay VectorClear(cg.predictedError); cg.thisFrameTeleport = qfalse; if (cg_showmiss->integer) { cgi.Printf("PredictionTeleport\n"); } } } Pmove(&cg_pmove); moved = qtrue; // add push trigger movement effects // CG_TouchTriggerPrediction(); if (!moved) { if (cg_showmiss->integer) { cgi.Printf("not moved\n"); } return; } // fire events and other transition triggered things CG_TransitionPlayerState(&cg.predicted_player_state, &oldPlayerState);}- L545–L555 OpenMoHAA starts prediction from the next snapshot's player state when one is pending and no teleport or no-prediction flag is set (an OPM addition, with the reason in the comment); otherwise from the current one.
- L570–L586 Every buffered command newer than the snapshot's
commandTimeand not from a previous map restart is replayed throughPmove(L615). This is reconciliation: the server's authoritative state plus the inputs it has not acknowledged yet. - L593–L602 The only use of
cg.predictedErrorin this function is to clear it on a teleport. ioquake3's error accumulation (its cg_predict.c L522–L571) is absent, so the decay applied incg_view.cL636–L645 always adds zero (fixture F12), andcg.thisFrameTeleportis cleared only when this branch runs (fixture F11). - L620, L668 Trigger prediction is commented out; the end-of-prediction transition hook exists but is an empty function at
cg_playerstate.c:38.
Shows: that the local player is not subject to the render delay \(B\) at all: their position is predicted forward past the newest snapshot. Does not show: the server's reconciliation policy for user commands (it simply runs them), or hit registration, which this post does not cover.
There is one inherited mechanism whose input has quietly disappeared. ioquake3 measures
the difference between its predicted position and the server's, stores it in
cg.predictedError, and decays it over cg_errordecay milliseconds
so that corrections are smoothed (cg_predict.c:522–571). OpenMoHAA's
view code still applies the decay:
// add error decay if (cg_errorDecay->value > 0) { int t; float f; t = cg.time - cg.predictedErrorTime; f = (cg_errorDecay->value - t) / cg_errorDecay->value; if (f > 0 && f < 1) { VectorMA(cg.refdef.vieworg, f, cg.predictedError, cg.refdef.vieworg); } else { cg.predictedErrorTime = 0; }
cg_view.c:635–646. But nothing writes a non-zero cg.predictedError at this revision (the only assignment in the predictor is the VectorClear on teleport), so the smoothing adds zero and corrections are applied as hard snaps. Whether that is intended (MOHAA's original client may well have behaved this way) or a leftover is fixture F12; either way it does not affect remote players, which is the subject of this post, and I mention it because the empty CG_TransitionPlayerState at cg_playerstate.c:38 is the same shape of thing.
For the timing model, prediction means the viewer's own position is ahead of the newest snapshot rather than behind it. The peeker sees themself at the corner about \(D_A + B\) earlier than anyone else sees them there, which is the peeker's advantage in the sense of the FDG study [24] and Chen's follow-up [25], and which section 9 has to keep separate from the disclosure lead: the two add up from the holder's point of view.
7. Where OpenMoHAA departs from ioquake3 Level 2
Medal of Honor: Allied Assault was built on a licensed Quake III Arena engine, and
OpenMoHAA is an open-source re-implementation that borrows heavily from ioquake3 for the
parts id Software published. It is tempting to summarise the
relationship in one of two wrong ways: "OpenMoHAA is ioquake3 with different weapons",
or "OpenMoHAA is its own engine". Neither survives reading the networking code. The
skeleton is Quake III's, function for function, and in many places comment for comment:
server frames, per-client snapshots, delta compression against the last acknowledged
frame, a 32-entry packet history, reliable commands on a UDP netchan, a client clock that
drifts, local prediction by command replay, and remote interpolation between two held
snapshots. Every one of those is described in the classic write-ups of the Quake III
model [17, 18, 20, 19] and every one of them is still
recognisable here. What changed is the state that flows through the skeleton
and the policies applied at three or four junctions, and those changes are
exactly the ones that matter for a first reveal. This section lists them, then shows
three of them side by side, pinned to ioquake3 commit
58839361 (2026-07-19).
7.1 Inherited skeleton, changed policies
| Mechanism | ioquake3 | OpenMoHAA at 667c50f9 | Matters for the reveal? |
|---|---|---|---|
| Server frames, integer timestamps | same shape | same shape; second float sv.frameTime in seconds (sv_init.c:552) | quantises \(S\) |
| Per-client snapshots, delta against last acknowledged, 32-deep history | PACKET_BACKUP 32 (qcommon.h:123) | unchanged (qcommon.h:188); extra residual byte (sv_snapshot.c:181–183) | no |
| Wire protocol | PROTOCOL_VERSION 71 (qcommon.h:247) | five MOHAA protocols 6, 8, 15, 16, 17 (q_shared.h:268–273); Spearhead and Breakthrough share 17; three entity field tables selected by protocol (msg.cpp:1958–1975) | no, but every field claim below is per protocol |
| Entity and player state fields | Quake III's | MOHAA's: lean angle (q_shared.h:1774), per-slot animation info (q_shared.h:2048), bone controllers not sent (q_shared.h:2053), eye position, camera fields | yes: what "position" means |
| Attachments (parents) | none | server judges a child by its parent's visibility (sv_snapshot.c:716–729); client sorts parents before children (cl_cgame.cpp:213–270); parent change forbids interpolation (S1) | yes |
| Cameras | none | snapshot viewpoint switches to the camera under PMF_CAMERA_VIEW (sv_snapshot.c:1087–1096); camera cuts forbid interpolation (cg_snapshot.c:317–322) | yes: where the server looks from |
| Own entity in own snapshot | excluded (sv_snapshot.c:476–484) | included (sv_snapshot.c:1098), stale comment kept (F9) | no |
| Snapshot viewpoint | ps->origin + viewheight (sv_snapshot.c:487–488) | vEyePos / viewangles, or camera (sv_snapshot.c:1086–1096) | yes |
SVF_CLIENTMASK | live (sv_snapshot.c:350–355) | commented out, "Removed in OPM" (sv_snapshot.c:670–680) | no |
| Far-plane and view-direction classifier | none | EntityDistCheck (Listing 4), all entity types | yes |
| Player-specific server-side visibility filter | none; PVS only | SV_ClientIsVisible with grace and 150 ms projection (Listing 5) | the subject of this post |
| Sounds for withheld players | none | non-PVS sound queue at the true origin (Listing 7) | yes: partial disclosure |
| Client clock | drifting offset (cl_cgame.c:823) | same policy (Listing 10) | sets \(B\) |
| Interpolation eligibility | new or teleported (cg_snapshot.c:216–220) | plus parent change, model change, teleported flag (S1) | yes |
| Teleport / respawn detection for the view | EF_TELEPORT_BIT on ps.eFlags (cg_snapshot.c:225) | PMF_RESPAWNED (cg_snapshot.c:306) | no |
| Trajectories | typed (trType, base, duration) (q_shared.h:1260–1266) | velocity and timestamp only (q_shared.h:1979–1982); linear with a 100 ms cap (S2) | yes: first-reveal extrapolation |
| Remote player smoothing cvar | cg_smoothClients "0", CVAR_USERINFO (cg_main.c:309) | "1", CVAR_ARCHIVE, plus cg_smoothClientsTime "100" (cg_main.c:155–156) | yes: chooses branch 3/4 over 2 |
| Local prediction | replay from cg.snap, with prediction-error decay (cg_predict.c:522–571) | replay from cg.nextSnap when possible (Listing 16); no error accumulation (F12); lean angle interpolated | only for the viewer's own body |
| Anim-time delta suppression | none | OPM fix using sv.frameTime (msg.cpp:2615–2624) | no |
7.2 Side by side: interpolation eligibility
The clearest example of structural inheritance with a policy change. The comment is the
same, the shape is the same, and OpenMoHAA adds two disqualifiers and a flag. Note also
the assignment of nextState: ioquake3 uses memcpy with the
struct copy left in a comment; OpenMoHAA uses the struct copy.
ioquake3 · ioquake/ioq3 · code/cgame/cg_snapshot.c · L211–L229 commit 58839361 · 2026-07-19
memcpy(¢->nextState, es, sizeof(entityState_t)); //cent->nextState = *es; // if this frame is a teleport, or the entity wasn't in the // previous frame, don't interpolate if ( !cent->currentValid || ( ( cent->currentState.eFlags ^ es->eFlags ) & EF_TELEPORT_BIT ) ) { cent->interpolate = qfalse; } else { cent->interpolate = qtrue; } } // if the next frame is a teleport for the playerstate, we // can't interpolate during demos if ( cg.snap && ( ( snap->ps.eFlags ^ cg.snap->ps.eFlags ) & EF_TELEPORT_BIT ) ) { cg.nextFrameTeleport = qtrue; } else { cg.nextFrameTeleport = qfalse; }
OpenMoHAA · openmoh/openmohaa · code/cgame/cg_snapshot.c · L287–L310 commit 667c50f9 · 2026-04-13
cent->nextState = *es; // if this frame is a teleport, or the entity wasn't in the // previous frame, don't interpolate if (!cent->currentValid || ((cent->currentState.eFlags ^ es->eFlags) & EF_TELEPORT_BIT) || (cent->currentState.parent != es->parent) || (cent->currentState.modelindex != es->modelindex)) { cent->interpolate = qfalse; // if this isn't the first frame and we have valid data, set the // teleport flag if (cent->currentValid) { cent->teleported = qtrue; } } else { cent->interpolate = qtrue; } } // if the next frame is a teleport for the playerstate, we // can't interpolate during demos if (cg.snap && (snap->ps.pm_flags & PMF_RESPAWNED)) { cg.nextFrameTeleport = qtrue; } else { cg.nextFrameTeleport = qfalse; }
For a first reveal both engines agree: an entity absent from the previous snapshot is not
interpolated. The MOHAA additions matter for a different discontinuity, a player whose
attachment or model changes (picking up a weapon that changes the model index, mounting
something), and for the teleported flag that lets CG_ResetEntity
skip its animation reset.
7.3 Side by side: trajectories
The second example is a case where MOHAA-specific state makes the code path materially
different. Quake III sends a typed trajectory with a base position and an optional
duration, and its evaluator switches on the type; a client-side player uses
TR_INTERPOLATE by default, which means "just use the base", or
TR_LINEAR_STOP when smoothing is on. MOHAA sends a velocity and a command
time, and its evaluator is one straight line, clamped.
ioquake3 · ioquake/ioq3 · code/game/bg_misc.c · L1197–L1234 commit 58839361 · 2026-07-19
void BG_EvaluateTrajectory( const trajectory_t *tr, int atTime, vec3_t result ) { float deltaTime; float phase; switch( tr->trType ) { case TR_STATIONARY: case TR_INTERPOLATE: VectorCopy( tr->trBase, result ); break; case TR_LINEAR: deltaTime = ( atTime - tr->trTime ) * 0.001; // milliseconds to seconds VectorMA( tr->trBase, deltaTime, tr->trDelta, result ); break; case TR_SINE: deltaTime = ( atTime - tr->trTime ) / (float) tr->trDuration; phase = sin( deltaTime * M_PI * 2 ); VectorMA( tr->trBase, phase, tr->trDelta, result ); break; case TR_LINEAR_STOP: if ( atTime > tr->trTime + tr->trDuration ) { atTime = tr->trTime + tr->trDuration; } deltaTime = ( atTime - tr->trTime ) * 0.001; // milliseconds to seconds if ( deltaTime < 0 ) { deltaTime = 0; } VectorMA( tr->trBase, deltaTime, tr->trDelta, result ); break; case TR_GRAVITY: deltaTime = ( atTime - tr->trTime ) * 0.001; // milliseconds to seconds VectorMA( tr->trBase, deltaTime, tr->trDelta, result ); result[2] -= 0.5 * DEFAULT_GRAVITY * deltaTime * deltaTime; // FIXME: local gravity... break; default: Com_Error( ERR_DROP, "BG_EvaluateTrajectory: unknown trType: %i", tr->trType ); break; }}
ioquake3 · ioquake/ioq3 · code/qcommon/q_shared.h · L1260–L1266 commit 58839361 · 2026-07-19
typedef struct { trType_t trType; int trTime; int trDuration; // if non 0, trTime + trDuration = stop time vec3_t trBase; vec3_t trDelta; // velocity, etc} trajectory_t;
OpenMoHAA · openmoh/openmohaa · code/cgame/cg_ents.c · L428–L441 commit 667c50f9 · 2026-04-13
void BG_EvaluateTrajectory(const trajectory_t *tr, int atTime, const vec3_t base, vec3_t result){ float deltaTime; if (atTime > cg_smoothClientsTime->integer + tr->trTime) { atTime = cg_smoothClientsTime->integer + tr->trTime; } deltaTime = (float)(atTime - tr->trTime) / 1000.0; result[0] = tr->trDelta[0] * deltaTime + base[0]; result[1] = tr->trDelta[1] * deltaTime + base[1]; result[2] = tr->trDelta[2] * deltaTime + base[2];}
OpenMoHAA · openmoh/openmohaa · code/qcommon/q_shared.h · L1979–L1982 commit 667c50f9 · 2026-04-13
typedef struct { int trTime; vec3_t trDelta;} trajectory_t;
The consumer differs accordingly. ioquake3's position function has three exits and no
cap: interpolate if eligible and typed TR_INTERPOLATE, interpolate a
TR_LINEAR_STOP client, otherwise evaluate the current state at the display
time, which for TR_LINEAR_STOP is bounded by the trajectory's own duration.
static void CG_CalcEntityLerpPositions( centity_t *cent ) { // if this player does not want to see extrapolated players if ( !cg_smoothClients.integer ) { // make sure the clients use TR_INTERPOLATE if ( cent->currentState.number < MAX_CLIENTS ) { cent->currentState.pos.trType = TR_INTERPOLATE; cent->nextState.pos.trType = TR_INTERPOLATE; } } if ( cent->interpolate && cent->currentState.pos.trType == TR_INTERPOLATE ) { CG_InterpolateEntityPosition( cent ); return; } // first see if we can interpolate between two snaps for // linear extrapolated clients if ( cent->interpolate && cent->currentState.pos.trType == TR_LINEAR_STOP && cent->currentState.number < MAX_CLIENTS) { CG_InterpolateEntityPosition( cent ); return; } // just use the current frame and evaluate as best we can BG_EvaluateTrajectory( ¢->currentState.pos, cg.time, cent->lerpOrigin ); BG_EvaluateTrajectory( ¢->currentState.apos, cg.time, cent->lerpAngles );
cg_ents.c:796–822. Compare the four branches of Listing 14: OpenMoHAA has no trType to switch on, so the choice is made by the cvar and the eligibility flag alone, and the bound on extrapolation is the client-side cg_smoothClientsTime rather than a server-written duration.
The practical difference for a first reveal: an ioquake3 client with default settings draws a newly appearing player at the snapshot's base position and never extrapolates them; an OpenMoHAA client with default settings extrapolates from the disclosed origin along the disclosed velocity for up to 100 ms whenever the next snapshot is late, and otherwise interpolates trajectory endpoints that were themselves advanced from the command time. The MOHAA path is smoother in the common case and wrong by a bounded amount (box D5) in the rare one.
7.4 The path that does not exist in ioquake3
The third comparison is the point of the post. ioquake3's entity walk has no per-player geometric test at all: after the flag checks, the double-add guard and the broadcast shortcut, it is area connectivity, then PVS, then add. Its snapshot builder also excludes the viewer's own entity and takes the viewpoint from the player origin plus view height.
ioquake3 · ioquake/ioq3 · code/server/sv_snapshot.c · L332–L380 commit 58839361 · 2026-07-19
// entities can be flagged to explicitly not be sent to the client if ( ent->r.svFlags & SVF_NOCLIENT ) { continue; } // entities can be flagged to be sent to only one client if ( ent->r.svFlags & SVF_SINGLECLIENT ) { if ( ent->r.singleClient != frame->ps.clientNum ) { continue; } } // entities can be flagged to be sent to everyone but one client if ( ent->r.svFlags & SVF_NOTSINGLECLIENT ) { if ( ent->r.singleClient == frame->ps.clientNum ) { continue; } } // entities can be flagged to be sent to a given mask of clients if ( ent->r.svFlags & SVF_CLIENTMASK ) { if (frame->ps.clientNum >= 32) Com_Error( ERR_DROP, "SVF_CLIENTMASK: clientNum >= 32" ); if (~ent->r.singleClient & (1 << frame->ps.clientNum)) continue; } svEnt = SV_SvEntityForGentity( ent ); // don't double add an entity through portals if ( svEnt->snapshotCounter == sv.snapshotCounter ) { continue; } // broadcast entities are always sent if ( ent->r.svFlags & SVF_BROADCAST ) { SV_AddEntToSnapshot( svEnt, ent, eNums ); continue; } // ignore if not touching a PV leaf // check area if ( !CM_AreasConnected( clientarea, svEnt->areanum ) ) { // doors can legally straddle two areas, so // we may need to check another one if ( !CM_AreasConnected( clientarea, svEnt->areanum2 ) ) { continue; // blocked by a door } } bitvector = clientpvs;
ioquake3 · ioquake/ioq3 · code/server/sv_snapshot.c · L472–L492 commit 58839361 · 2026-07-19
// grab the current playerState_t ps = SV_GameClientNum( client - svs.clients ); frame->ps = *ps; // never send client's own entity, because it can // be regenerated from the playerstate clientNum = frame->ps.clientNum; if ( clientNum < 0 || clientNum >= MAX_GENTITIES ) { Com_Error( ERR_DROP, "SV_SvEntityForGentity: bad gEnt" ); } svEnt = &sv.svEntities[ clientNum ]; svEnt->snapshotCounter = sv.snapshotCounter; // find the client's viewpoint VectorCopy( ps->origin, org ); org[2] += ps->viewheight; // add all the entities directly visible to the eye, which // may include portal entities that merge other viewpoints SV_AddEntitiesVisibleFromPoint( org, frame, &entityNumbers, qfalse );
OpenMoHAA · openmoh/openmohaa · code/server/sv_snapshot.c · L670–L680 commit 667c50f9 · 2026-04-13
// entities can be flagged to be sent to a given mask of clients // Removed in OPM // This doesn't make sense it it only supports half of the client /* if ( ent->r.svFlags & SVF_CLIENTMASK ) { if (frame->ps.clientNum >= 32) Com_Error( ERR_DROP, "SVF_CLIENTMASK: clientNum > 32\n" ); if (~ent->r.singleClient & (1 << frame->ps.clientNum)) continue; } */
OpenMoHAA · openmoh/openmohaa · code/server/sv_snapshot.c · L742–L761 commit 667c50f9 · 2026-04-13
// ignore if not touching a PV leaf // check area if ( !CM_AreasConnected( clientarea, svCheckEnt->areanum ) ) { // doors can legally straddle two areas, so // we may need to check another one if ( !CM_AreasConnected( clientarea, svCheckEnt->areanum2 ) ) { continue; // blocked by a door } } if (g_gametype->integer != GT_SINGLE_PLAYER && !(ent->r.svFlags & SVF_NOFARPLANE)) { float farplane = sv.farplane; if (farplane < 1) farplane = 12000; if (farplane > 12000) farplane = 12000; check = EntityDistCheck(origin, forward, parentEnt ? parentEnt : ent, farplane, ps->fov); if (check == CULL_OUT) { continue; }
OpenMoHAA · openmoh/openmohaa · code/server/sv_snapshot.c · L802–L810 commit 667c50f9 · 2026-04-13
if (g_gametype->integer != GT_SINGLE_PLAYER && ent->s.number < svs.iNumClients) { if (!SV_ClientIsVisible(ent->s.number, client - svs.clients, check, forward, right)) { SV_AddNonPVSSound(client, ent); continue; } } // add it SV_AddEntToSnapshot( svEnt, ent, eNums, portalEnt, portalsky);
OpenMoHAA · openmoh/openmohaa · code/server/sv_snapshot.c · L1086–L1098 commit 667c50f9 · 2026-04-13
// find the client's viewpoint if (ps->pm_flags & PMF_CAMERA_VIEW) { VectorCopy(ps->camera_origin, org); VectorCopy(ps->camera_angles, ang); } else { VectorCopy(ps->vEyePos, org); VectorCopy(ps->viewangles, ang); } SV_AddEntToSnapshot(svEnt, SV_GentityNum(client - svs.clients), &entityNumbers, NULL, qfalse);
The left column is the whole of what a stock ioquake3 server does to decide whether you learn where another player is: is the entity in an area connected to yours, and is one of its clusters in your potentially visible set. That is the model in which wallhacks are cheap, and in which the peeker's-advantage literature was mostly written [24]. The right column adds a distance and direction classifier for every entity, a per-player line-of-sight test with memory and look-ahead, and an audio side channel for the players it withholds, all evaluated from the eye position rather than the origin. Nothing in the right column has a counterpart on the left, and the client code that has to cope with its consequences (sections 6.2 to 6.4) is the inherited Quake III client code, unchanged in the parts that decide what a first reveal looks like. That asymmetry, a server that withholds for reasons the client was never designed around, is why the timing model of sections 3 and 4 is the right lens for this engine in particular.
Two more references belong here for readers who want to go beyond what I cover. The Unlagged project's networking primer [20] and the sh-unlagged mod are the classic treatment of the other half of the fairness problem, lag compensation for hits, which this post deliberately leaves alone; van Waveren's DOOM III paper [10] describes id's next architecture, which kept per-client PVS filtering and dropped snapshot interpolation in favour of a different client model; and the netcodesimulator project is a small sandbox for the same four-clocks reasoning that does not depend on any engine.
8. Observations that deserve fixtures Level 2
Reading code closely turns up places where the source and the intent visible in the comments do not obviously agree. I want to be careful about what I do with those. A person reading a reimplementation of a twenty-year-old engine, without the original source, without the original developers, and without measurements, is not in a position to call any of them a bug: several are almost certainly faithful reproductions of the original binary's behaviour, some are deliberate simplifications, and at least one may be my misreading. What each of them deserves is a deterministic fixture: a scripted scene with known positions and velocities, run through the function in isolation, with the expected output written down in advance. If the fixture passes, the observation was noise. If it fails, the project has a test to decide what it wants. The table lists fourteen, in the order they appear in the post; all of them are filed here as fixture candidates and none as defects.
| # | Observation (pinned) | Why it matters for timing | Deterministic fixture | Telemetry hook |
|---|---|---|---|---|
| F1 | Lean offsets computed then overwritten by plain eye positions (sv_snapshot.c:466–484) | a leaning peeker's eye is up to 30 units from where the trace starts; the trace may pass or fail earlier than the real eye would | viewer leaning fully left at a wall edge, target behind it: assert the trace endpoints equal the leaned eye positions, or document that they are meant not to | log the two endpoints alongside fLeanAngle on every trace |
| F2 | dot from present positions reused for the projected trace (sv_snapshot.c:489, sv_snapshot.c:506) | the lowered second ray may be skipped or attempted based on a direction that is stale by 150 ms | viewer turning through 90° during the horizon: assert whether the projected trace attempts the second ray | log dot and which rays ran |
| F3 | Unused dir in the trace helper (sv_snapshot.c:411–417) | none; the commented VectorMA suggests an intended offset along the line | none needed; note for readers | none |
| F4 | Projection branch gated on the target's speed only, although both endpoints are projected (sv_snapshot.c:495–504) | a stationary holder is disclosed to a moving peeker only by the present trace: \(H \in (-f, 0]\) for the peeker, \(H \in (100,150]\) ms for the holder (box D4) | scripted peek at 250 u/s past a 90° corner with a stationary holder: record the snapshot index of first inclusion for both clients; expected asymmetry of two to four frames under current code | per-crossing \(S\) for each viewer |
| F5 | EntityDistCheck: dir points entity→viewer (sv_snapshot.c:559), stationary players scaled ×0.25 (sv_snapshot.c:569–571), and CULL_IN under mode 1 bypasses the trace (sv_snapshot.c:454–457) | in mode 1 a stationary player within 2560 units who passes the PVS test is disclosed with no line-of-sight trace at all; the sign of dir makes entities behind the viewer the ones culled early, which may be intended | viewer at origin facing +x; entities at ±3000 on x, stationary and moving: assert the four classifications; then mode 1 with a wall between viewer and a stationary target at 1000 units: assert inclusion | log check and the mode on every classification |
| F6 | Portal and sky-portal recursion pass the outer viewer's angles (sv_snapshot.c:813–820) | the far-plane direction term and the trace dot for entities seen through a portal use the wrong forward vector | portal facing −x with viewer facing +x: assert classifications on the far side | log the forward vector per recursion level |
| F7 | sv.farplane stored as \((d+32)^2\) (sv_game.c:1469–1478) but clamped to \([1,12000]\) and compared linearly (sv_snapshot.c:755–758) | any map far plane above about 78 units saturates the clamp, and a far plane of zero is mapped to the same ceiling, so the effective far cut is 12128 units in both cases; only far planes between 1 and 77 units produce a distinct, squared, value | set far plane 4000, place a target at 5000 units in clear view: assert whether it is included | log the clamped value once per map |
| F8 | First reveal extrapolated up to 100 ms when the next snapshot is late (cg_ents.c:518–522, cg_ents.c:432–434) | the late path draws the disclosed player past the disclosed origin along a possibly stale velocity, then freezes | feed two snapshots with the second delayed 40 ms and a velocity of 250 u/s: assert the rendered origin advances 25 units then stops | log branch taken and extrapolation time on first draw |
| F9 | Own entity included in own snapshot (sv_snapshot.c:1098) under a comment that says the opposite (sv_snapshot.c:1075–1076) | none for the reveal; bandwidth and clarity | assert presence of entity clientNum in the snapshot | none |
| F10 | Non-PVS sounds carry the withheld player's true origin (sv_snapshot.c:316–327) | a side channel that discloses exact positions at footstep rate (box D8) | hidden walking player: assert the sound origins equal the player's origins | count non-PVS sounds per withheld player per second |
| F11 | cg.thisFrameTeleport cleared only inside the replay branch (cg_predict.c:593–597) | prediction only; the flag can persist across a frame in which no command was replayed | teleport with no pending commands: assert the flag state on the next frame | none |
| F12 | cg_errordecay applied (cg_view.c:635–646) to an error nothing accumulates | prediction only; corrections snap | inject a 10-unit server correction: assert the view moves in one frame | none |
| F13 | Dead local resetTime next to the RESET_TIME constant (cl_cgame.cpp:1062–1066 vs cl_cgame.cpp:1071) | none; readability | none needed | none |
| F14 | Residual byte written (sv_snapshot.c:181–183) and read (cl_parse.cpp:291) but never used | a sub-frame timestamp that could sharpen the client clock's estimate of \(S\) is discarded | none needed; design note | if used, log it with each snapshot |
Three smaller ones, for completeness: the loop head computes a num that
nothing reads, so sv_drawentities has no effect on the walk
(sv_snapshot.c:621–624); the local player's eligibility flag set at
cg_snapshot.c:280 is recomputed by the loop below it; and the
per-viewer sound queue is capped at four, so a player who makes five sounds in a frame
discloses four. None of these changes the timing model.
9. Modelling the corner probabilistically Level 1 Level 3
Sections 3 to 6 gave the corner a vocabulary and OpenMoHAA a mechanism. Neither gives a number. The horizon multiplier is three frames because someone chose three; the grace timer is 200 ms because someone chose 200. I do not think those choices are wrong. I think nobody, including me, knows whether they are right, because "right" depends on quantities that vary from corner to corner and link to link, and the only way to know their distribution is to measure it. This section is my attempt to say what a measurement-driven answer would look like. It is a model, not a result: I have no telemetry from a populated OpenMoHAA server, and every number in the figures below is simulated from assumptions that are stated where they are made.
9.1 Why one number is not enough
Take the three delays of section 3.3 one at a time. The delivery delay \(D_A\) is half a round trip plus queueing, and it jitters; a lost snapshot costs a whole period, and the next one arrives as a delta against an older base. The render delay \(B\) sits near one snapshot period in steady state (section 6.1) but walks off by tens of milliseconds after every clock reset and takes a second and a half to return, and it grows by up to the extrapolation cap when the next snapshot is late (section 6.4). The disclosure lead \(H\) is anywhere in \((-f, kf]\) depending on whether the approach is straight, curved, or made by a player who is standing still (box D4, fixture F4). None of these is a constant, and the interesting events live in the tails: the corner where a low-ping cheat gets a 120 ms lead, the corner where a high-ping player sees the attacker 150 ms late.
Worse, the population is heterogeneous in exactly the direction that makes a single setting bad. The cheat window is \(W = [H - D_A]_+\): it is largest for the cheater with the best link. The lateness is \(L = [D_A + B - H]_+\): it is largest for the honest player with the worst link. One \(H\) for everyone is therefore at once too generous to the low-ping cheat and too stingy to the high-ping player, which is the observation behind the per-client horizon of box D10. Any evaluation that reports an average over players hides this.
There is measurement work to build on. Tokey, Chen, Claypool and colleagues ran a controlled study of how much the peeker's advantage grows with latency [24]; Chen models who wins a peek under asymmetric latency [25]; Liu, Xu and Claypool's survey lays out the space of compensation techniques whose effects one would be measuring [26]; Riot describe sizing a client buffer from the measured jitter of their population rather than from a rule of thumb [27]; and two of Tariq10x's videos make, in a different register, the same case that FPS timing arguments should be settled by simulation and measurement rather than folklore [28, 29]. What I want from a model is narrower than any of those: for a setting \(k\) and a population of links and corners, the predicted distribution of \(W\) and \(L\), and how sure we are about it.
Bayesian modelling is the natural tool because the question is about unobserved future corners and the data are noisy, sparse in the tails, and measured through instruments that are themselves imperfect. The discipline matters more than the label:
- Separate the latent quantities (the real \(S\), \(A\), \(R\), \(V\)) from the measurement process (logged timestamps on two unsynchronised clocks, a crossing time reconstructed offline from positions).
- Choose likelihoods that match the data: positive, heavy-tailed delays; a lead that is quantised to frames; events that are censored when the player is never drawn.
- Write priors in physical units and inspect what they imply before looking at data.
- Carry every uncertainty through to the decision, so that the answer is a distribution over settings, not a point.
In one line: logged timestamps are not physical timing, and a model that treats them as such will be confidently wrong in the tails, which is where the corner is decided.
9.2 A generative model of the corner
I find it easier to trust a model that says how one event could have been generated than one that fits a curve to a histogram. For a single corner crossing \(i\), observed by one viewer, this is the story. The server discloses the target with lead \(H_i\), which section 5.9 showed is the horizon \(k f_i\) scaled by how much of it the geometry realised, \(\varphi_i \in [0,1]\), minus a frame-quantisation residual \(\rho_i \in (-f_i, 0]\). A straight approach gives \(\varphi_i = 1\); a curved one, or a stationary target under the speed gate, gives \(\varphi_i = 0\); intermediate values happen when the projected point clears the corner only some frames after it could have. The snapshot takes \(D_{A,i}\) to arrive, which is positive and heavy-tailed and depends on the link. The client then takes \(B_i\), which depends on its clock state and on whether the next snapshot is late. Some crossings end without the target ever being drawn, and those must be kept as censored observations rather than dropped. And every one of these is measured with error: the client's clock is offset from the server's, and \(V\) is reconstructed after the fact from logged positions.
Box D6 writes this down as a hierarchical model with partial pooling across clients, sessions, maps and corners, and gives illustrative priors in milliseconds. Box D8 looks at the same corner through information theory and explains why a cheat wants time rather than bits, and what the non-PVS sounds of section 5.8 leak.
Level 3A hierarchical model of one corner crossing, with priors in milliseconds
Question: what is a generative model of a corner crossing that respects the structure of sections 3 to 6, and what do its priors mean in physical units?
Index crossings by \(i\), with client \(c[i]\), session \(s[i]\), map \(m[i]\), corner \(g[i]\) and time block \(t[i]\). Let \(f_i\) be the server frame length and \(P_i\) the snapshot period the client asked for. The structural part is
$$\begin{aligned} \log D_{A,i} &\sim t_{\nu_A}\!\big(\mu_{A,i},\,\sigma_A\big), & \mu_{A,i} &= \alpha_A + \beta_r \log\!\tfrac{\mathrm{RTT}_i}{2} + \beta_j \log(1+\mathrm{jitter}_i) + u^{(c)}_{c[i]} + u^{(s)}_{s[i]} + m_{t[i]},\\[2pt] \log B_i &\sim t_{\nu_B}\!\big(\mu_{B,i},\,\sigma_B\big), & \mu_{B,i} &= \alpha_B + \beta_P \log\!\tfrac{P_i}{50} + \beta_X X_i + v^{(c)}_{c[i]},\\[2pt] H_i &= k\, f_i\, \varphi_i + \rho_i, & \rho_i &\sim \mathrm{Uniform}(-f_i,\,0],\\[2pt] \varphi_i &\sim \mathrm{ZOIB}\!\big(\pi^0_i,\ \pi^1_i,\ \pi_i \kappa,\ (1-\pi_i)\kappa\big), & \operatorname{logit}\pi_i &= \boldsymbol z_i^{\top}\boldsymbol\theta + u^{(m)}_{m[i]} + u^{(g)}_{g[i]}, \end{aligned} \tag{15} $$Read the five lines in words before the symbols. First line: the log of the delivery delay is Student-\(t\) around a level set by the viewer's link (half the round trip, the jitter), plus learned offsets for who the client is, which session it is, and a slowly drifting time block. Second: the same shape for the render delay, with the snapshot period and the extrapolation flag in place of the link. Third: box D4's decomposition \(H = k f \varphi + \rho\), with the frame residual \(\rho\) uniform over one frame. Fourth and fifth: the realised fraction \(\varphi\) may be exactly 0, exactly 1, or anything in between, with the probabilities of those outcomes driven by the geometry of the approach and by per-map and per-corner offsets. The outcomes then follow section 3.3 exactly: \(D_{R,i} = D_{A,i} + B_i\), \(W_i = [H_i - D_{A,i}]_+\) and \(L_i = [D_{R,i} - H_i]_+\). Here \(X_i\) indicates that the client was extrapolating when the target became current (section 6.4), so that \(B_i\) can carry the cap; \(\mathrm{ZOIB}\) is a zero-and-one-inflated Beta, needed because straight approaches give \(\varphi_i = 1\) exactly and curved ones \(\varphi_i = 0\) exactly, with the point masses \(\pi^0_i, \pi^1_i\) themselves logistic in \(\boldsymbol z_i\); and for a stationary target the speed gate forces \(\varphi_i \equiv 0\), which enters as an observed covariate rather than a parameter (fixture F4). The features \(\boldsymbol z_i\) are what the server can log at the moment of the check: target speed, closing speed, the present-trace and projected-trace verdicts, the approach angle to the corner, distance, lean state. The random walk \(m_t\) absorbs slow drift in network conditions during a session.
The measurement part is what makes the model honest. Client timestamps live on a clock offset from the server's by an unknown \(o_{c[i]}\) that the engine estimates but does not know; the crossing time is reconstructed offline from logged eye positions with an error that depends on how fast the geometry was changing:
$$\widetilde A_i = A_i + o_{c[i]} + \epsilon_{A,i},\qquad \widetilde R_i = R_i + o_{c[i]} + \epsilon_{R,i},\qquad \widetilde V_i \sim \mathcal N\!\big(V_i,\ s_{V,i}^2\big),\qquad o_{c} \sim \mathcal N(\hat o_c,\ \tau_o^2), \tag{16} $$where \(\hat o_c\) comes from the netchan's own round-trip estimate and \(\tau_o\) is its believed precision. A crossing in which the target was never drawn within the window \(T^{\max}_i\) contributes the survival term \(P(R_i > T^{\max}_i)\) instead of a density; dropping those events would select on the outcome and bias every delay downwards. For the same reason the server must log every visibility candidate, not only the inclusions: the filter under study is the one doing the selecting.
Priors, in units a server operator can argue with; they are examples to be checked by prior-predictive simulation, not recommendations:
$$\begin{aligned} \alpha_A &\sim \mathcal N(\log 60\ \mathrm{ms},\,0.6), & \alpha_B &\sim \mathcal N(\log 55\ \mathrm{ms},\,0.5), & \beta_{\bullet} &\sim \mathcal N(0,\,0.5),\\ \sigma_A, \sigma_B &\sim \mathrm{HalfNormal}(0.5), & \nu_\bullet &= 2 + \mathrm{Exponential}(1/10), & \boldsymbol\theta &\sim \mathcal N(0,\,1),\\ \kappa &\sim \mathrm{Gamma}(2,\,0.1), & \tau_u &\sim \mathrm{HalfNormal}(0.5), & \tau_o &\sim \mathrm{HalfNormal}(10\ \mathrm{ms}). \end{aligned} \tag{17} $$The \(\alpha_A\) prior says a median delivery delay of 60 ms with a 95% range of roughly 18 to 200 ms before seeing data; \(\alpha_B\) centres the render delay on the steady state of section 6.1 at 20 Hz. The Student-\(t\) tails let a link spike without dragging \(\sigma\) with it. Partial pooling lets a corner seen twenty times inform one seen twice. A prior-predictive check draws whole matches from these priors alone and asks whether they produce ten-second delays, negative leads, or a lateness that never happens; if they do, the model is fixed before the data are consulted.
with pm.Model(coords=coords) as corner_model:
# partial pooling over clients, sessions, maps and corners (non-centred)
u_client = pm.Normal("u_client_raw", 0, 1, dims="client") * pm.HalfNormal("tau_client", 0.5)
u_corner = pm.Normal("u_corner_raw", 0, 1, dims="corner") * pm.HalfNormal("tau_corner", 0.5)
# delivery delay D_A: log Student-t, location from the link
alpha_A = pm.Normal("alpha_A", np.log(60.0), 0.6)
beta_A = pm.Normal("beta_A", 0, 0.5, dims="net_feature")
sigma_A = pm.HalfNormal("sigma_A", 0.5)
nu_A = pm.Exponential("nu_A_minus_2", 0.1) + 2
mu_A = alpha_A + X_net @ beta_A + u_client[client_id]
log_DA = pm.StudentT("log_D_A", nu=nu_A, mu=mu_A, sigma=sigma_A,
observed=np.log(D_A_obs_ms), dims="event")
# render delay B: same shape, centred on one snapshot period
alpha_B = pm.Normal("alpha_B", np.log(55.0), 0.5)
...
# realised fraction of the horizon: Beta regression on what the server logged
theta = pm.Normal("theta", 0, 1, dims="geom_feature")
kappa = pm.Gamma("kappa", 2, 0.1)
pi = pm.math.invlogit(Z_geom @ theta + u_corner[corner_id])
phi = pm.Beta("phi", pi * kappa, (1 - pi) * kappa, observed=phi_obs, dims="event")
# derived quantities for a candidate k (frames) and frame length f (ms)
rho = pm.Uniform("rho", -f_ms, 0, dims="event")
H = pm.Deterministic("H", k * f_ms * phi + rho)
W = pm.Deterministic("W", pt.maximum(H - pm.math.exp(log_DA), 0))
L = pm.Deterministic("L", pt.maximum(pm.math.exp(log_DA) + B - H, 0))
The sketch does run. I completed its elided parts in the most literal way (the render-delay block mirrors the delivery block; \(\varphi\) stays a plain Beta), simulated 600 crossings from known parameter values across twelve clients and eight corners, and sampled: four chains of 1000 draws, about ten seconds on a laptop, PyMC 6.3. Nothing PyMC-like runs inside a web page, so the numbers below are pasted from that local run, not computed live. No divergences, every split-\(\hat R\) at 1.00, and every generating value inside its 94% posterior interval:
parameter truth post mean 94% interval r_hat
alpha_A 4.01 4.08 [ 3.94, 4.22] 1.00
beta_A[log_rtt_half] 0.90 0.91 [ 0.85, 0.97] 1.00
beta_A[log1p_jitter] 0.30 0.31 [ 0.27, 0.36] 1.00
sigma_A 0.30 0.30 [ 0.27, 0.32] 1.00
nu_A_minus_2 4.00 3.03 [ 1.88, 4.58] 1.00
tau_client 0.20 0.26 [ 0.17, 0.40] 1.00
alpha_B 4.06 4.06 [ 4.04, 4.08] 1.00
sigma_B 0.22 0.22 [ 0.21, 0.24] 1.00
theta[closing_speed] 1.10 1.04 [ 0.97, 1.10] 1.00
theta[approach_angle] -0.70 -0.68 [ -0.74, -0.62] 1.00
kappa 8.00 7.92 [ 7.14, 8.76] 1.00
tau_corner 0.35 0.28 [ 0.16, 0.49] 1.00
derived, at k = 3 and f = 50 ms, over the same 600 synthetic crossings:
posterior mean E[W] = 14.1 ms P(W > 0) = 0.54
posterior mean E[L] = 91.4 ms P(L > 0) = 0.98
A synthetic-data check, nothing more: the "truth" column is what the fake telemetry was generated from, so the table shows that the model recovers what it should when the world matches it, which is a sanity check and not evidence about any real server. Two honest wrinkles. The tail exponent \(\nu\) is the least pinned-down parameter at 600 events, and section 9.1 said the decision lives in the tails, so that is exactly the parameter more telemetry has to firm up. And with this simulated geometry mix (mean \(\varphi\) near one half) lateness dominates at \(k = 3\): box D7's flat-loss regime, showing up in numbers.
Two remarks on the shape. First, \(k\) enters only through \(H\), and only multiplied by \(\varphi\): in a population with many curved approaches and stationary targets, the model will correctly say that \(k\) barely matters, which is a finding, not a failure. Second, the code fixes \(k = 3\) as an integer; the model treats it as a knob so that the decision analysis of section 9.3 can compare settings, but the settings actually available are integers, or a per-client rule as in box D10. General references for this style of model are Gelman et al. [14] and McElreath [15].
Level 3An information view: why a cheat wants time rather than bits, and what the sounds leak
Question: how much does an early disclosure tell a cheat about the corner, how does that change with the lead \(W\), and how much do the non-PVS sounds of section 5.8 disclose about a player the server is otherwise withholding?
Let \(X_{V-W}\) be the target's disclosed state (origin and velocity) at lead \(W\) before the crossing, and let the cheat's object of interest be the crossing time \(V\). The information the disclosure carries about \(V\) is the mutual information \(I(V; X_{V-W}) = h(V) - h(V \mid X_{V-W})\): in plain terms, by how many bits the disclosure shrinks the cheat's uncertainty about when the peek will come. Before disclosure the cheat's uncertainty about \(V\) is whatever the prior over corners gives, say a spread \(\sigma_V\) of a second or more. After disclosure at lead \(W\) the remaining uncertainty is the target's freedom to change course in the next \(W\) milliseconds, which grows with \(W\); under a local Gaussian approximation with residual spread \(s(W) = s_0 + c\,W\),
$$I(W) \approx \tfrac12 \log_2 \frac{\sigma_V^2}{(s_0 + c W)^2}, \tag{18} $$which is decreasing in \(W\). The earlier the server discloses, the fewer bits the disclosure carries about when the peek will happen, because the peeker has not decided yet. Meanwhile the cheat's usable lead is \(W\) itself: time to pre-aim, to reposition, to decide not to peek. So information content and tactical value move in opposite directions in \(W\). This is why the loss in box D2 and section 9.3 is written in milliseconds of \(W\) and not in bits: a cheat does not want to know more, it wants to know sooner. It also explains why the server's projection is well targeted: it discloses at the last moment at which disclosure is nearly certain to be needed, which is where \(I\) is highest and \(W\) smallest.
The sound channel is different. Each non-PVS sound in Listing 7 carries the withheld player's exact origin at the time of the sound (fixture F10). If the cheat's prior over that player's position is uniform over a region of area \(\mathcal A\) at a resolution of \(r \times r\) units, one sound is worth up to
$$I_{\text{sound}} \le \log_2 \frac{\mathcal A}{r^2}, \tag{19} $$which for a 2000-by-2000 unit region at 16-unit resolution is about 14 bits, delivered at footstep rate. A few footsteps a second are enough to track the player continuously through the wall, which is what a sound-driven overlay would do. A legitimate client receives the same origins but perceives them through spatialised audio at a far lower resolution, a few tens of degrees of azimuth and a rough sense of distance, and the difference between those two resolutions is exactly the channel's value to a cheat. The bound is an upper bound; how much of it is realised on a real server depends on how often withheld players make sounds, which is the count the F10 telemetry hook records. My reading is that the designers accepted this channel knowingly (the documentation advertises footsteps for culled players), and the right response is to measure its rate rather than to remove it, since footsteps through walls are part of the game.
9.3 From posterior draws to a server setting
Inference says what the system does. A decision asks what the server should do. Suppose the model of box D6 has been fitted to telemetry and we hold \(S\) posterior draws \(\theta^{(1)}, \ldots, \theta^{(S)}\). For each draw and each candidate horizon \(h = k f\), simulate future corners and record
$$W^{(s)}(h) = \big[H^{(s)}(h) - D_A^{(s)}\big]_+,\qquad L^{(s)}(h) = \big[D_A^{(s)} + B^{(s)} - H^{(s)}(h)\big]_+ . \tag{20} $$Then score each setting with a loss that says how much a millisecond of cheat window costs relative to a millisecond of lateness, and, because a rare 250 ms invisible attacker matters more than many harmless 2 ms misses, adds terms for the tails:
$$\mathcal J(h) = c_W\,\mathbb E[W \mid h] + c_L\,\mathbb E[L \mid h] + c_{WT}\,P(W > w_0 \mid h) + c_{LT}\,P(L > \ell_0 \mid h). \tag{21} $$The setting to choose minimises the loss averaged over the posterior. The spread of the per-draw minimisers says how much the choice depends on what is still unknown. Figure 5 shows the frontier for one simulated population, and Figure 6 the spread; box D7 states precisely what the histogram is and is not. Everything in both figures is simulated from the assumptions printed in their captions, not measured from OpenMoHAA.
Figure 5 · A posterior-predictive frontier
Each curve is computed over 7000 simulated corners. Move the horizon multiplier and watch the two failure probabilities trade against each other; change the link or the costs and watch the preferred setting move while the shape of the trade stays.
Figure 5. Simulation, not data. Delivery delay is log-normal around the chosen median with the chosen jitter; render delay is log-normal around 55 ms with sd 0.25 (the steady state of section 6.1); the lead is \(H = h\varphi + \rho\) with \(\rho\) uniform on \((-50, 0]\) and \(\varphi = 1\) for the chosen share of corners and uniform on \([0,1]\) otherwise (box D4). The loss is \(\mathbb E[W] + (c_L/c_W)\,\mathbb E[L]\) in milliseconds, without tail terms. Moving \(k\) right lowers lateness and raises disclosure; changing the link moves the curves; changing the costs moves the best point. The shape survives all three.
Figure 6 · How sure would we be about the best \(k\)?
Each bar counts posterior draws whose loss-minimising \(k\) fell in that bin, where each draw perturbs the population's median delay, render delay, jitter and share of straight approaches by amounts standing in for posterior uncertainty. The teal line is the minimiser of the loss averaged over draws, which is the actual decision.
Figure 6. A wide histogram means the telemetry would not yet justify a precise setting; a narrow one means the decision is stable even though the parameters are not known. A Bayesian answer to "what should \(k\) be" is the teal line together with this spread, never a false precision such as 2.87 frames. Recomputed with the sliders of Figure 5.
Level 3The decision loss: how the balance condition and the posterior combine, and what Figure 6 is not
Question: how do the per-corner losses of box D2 and the posterior draws combine into a single choice of \(k\), and what does the histogram of per-draw minimisers in Figure 6 actually measure?
Write \(\mathcal J(h;\theta)\) for the loss of the previous paragraph evaluated under parameters \(\theta\), which fixes the distributions of \(D_A\), \(B\) and \(\varphi\). The Bayes decision minimises the posterior expected loss,
$$\bar{\mathcal J}(h) = \int \mathcal J(h;\theta)\, p(\theta \mid \mathcal D)\, d\theta \;\approx\; \frac1S \sum_{s=1}^{S} \mathcal J\big(h;\theta^{(s)}\big),\qquad h^\star = \arg\min_h \bar{\mathcal J}(h). \tag{22} $$Without the tail terms, \(\bar{\mathcal J}\) has the same U shape as the loss of box D2: averaging over draws mixes the delay distributions but keeps the shape, so there is a single best lead, and it is characterised by the same balance as before, now over the mixture. \(h^\star\) is the point at which the posterior-predictive probability that a random corner's \(D_A\) falls below \(h\), weighted by \(c_W\), balances the probability that its \(D_R\) exceeds \(h\), weighted by \(c_L\). With the tail terms \(c_{WT}P(W > w_0)\) and \(c_{LT}P(L > \ell_0)\) the U shape is no longer guaranteed, and a grid search over the handful of integer \(k\) actually available is the honest method.
Figure 6 plots a different object: the per-draw minimisers \(h^{(s)} = \arg\min_h \mathcal J(h;\theta^{(s)})\). This is not a posterior distribution of \(h\), because \(h\) is a decision and not a parameter, and in general \(h^\star\) is neither the mean nor the median of the \(h^{(s)}\). The histogram answers a diagnostic question: if we knew \(\theta\), how much would the best setting move as \(\theta\) ranges over what the data still allow? A wide histogram with a sharp \(\bar{\mathcal J}\) minimum can happen (the draws disagree but the average does not), and so can a narrow histogram with a flat \(\bar{\mathcal J}\), when every draw agrees that nothing much depends on \(h\).
That second case is worth naming. Only \(H\) depends on \(h\), and only through \(\varphi\); writing \(\mathbf 1\{\cdot\}\) for the indicator, 1 when the event inside holds and 0 otherwise,
$$\frac{\partial\,\mathbb E[W]}{\partial h} = \mathbb E\big[\varphi\,\mathbf 1\{H > D_A\}\big],\qquad \frac{\partial\,\mathbb E[L]}{\partial h} = -\,\mathbb E\big[\varphi\,\mathbf 1\{H < D_R\}\big]. \tag{23} $$In a population where most approaches are curved or most targets stationary, \(\mathbb E[\varphi]\) is small, both derivatives are small, and the loss is flat in \(h\): the setting does not matter much, and no amount of data will make the histogram narrow. A flat loss is indifference, not ignorance. The same identity is why a per-client horizon (box D10) is a different decision problem with a different optimum: it changes the coupling between \(h\) and \(D_A\) that these derivatives assume away.
10. What to log, and how not to fool ourselves Level 2
The model of section 9 is only as good as its event trace, and the trace has to be collected at the places in the code where the four clocks are actually stamped. The useful unit is one visibility candidate: one viewer, one target, one server frame in which the target was considered for inclusion. Not a per-packet average and not a per-match summary, because both erase the tails, and not only the inclusions, because the filter under study is the thing doing the selecting. Every point below is a place I would add a log line, pinned to the revision this post reads.
10.1 Instrumentation points
| Point | Where (pinned) | What to record | Defines |
|---|---|---|---|
| Visibility verdict | SV_ClientIsVisible, sv_snapshot.c:436–513 | viewer, target, check, which return fired (mode, CULL_IN, grace, present trace, speed gate, projected trace, fail), svs.time, dot, target speed, both eye positions | \(S\), and the gate that produced it |
| Snapshot inclusion | SV_AddEntitiesVisibleFromPoint, sv_snapshot.c:802–810 | snapshot sequence, svs.time, entity number, whether this is the first inclusion after at least one exclusion | \(S\) per viewer; the F4 asymmetry |
| Withheld-player sounds | SV_AddNonPVSSound, sv_snapshot.c:310–329 | count and origins per viewer, withheld player and frame | the D8 side channel; F10 |
| Snapshot header | SV_WriteSnapshotToClient, sv_snapshot.c:159–183 | svs.time, residual byte, sequence, delta base | \(S\) on the wire; F14 |
| Client arrival | CL_ParseSnapshot, cl_parse.cpp:270 | cls.realtime at parse, serverTime, delta base, current cl.serverTimeDelta | \(A\) and \(D_A\), through the clock offset |
| Client clock | CL_SetCGameTime, cl_cgame.cpp:1249–1274 | cl.serverTime, extrapolatedSnapshot, every reset and halving in Listing 10 | the first component of \(B\) |
| Entity becomes current | CG_SetNextSnap, cg_snapshot.c:287–310; CG_TransitionEntity, cg_snapshot.c:113–131 | first currentValid, interpolate, teleported, cg.time | the transitions of section 6.2 |
| First position | CG_CalcEntityLerpPositions, cg_ents.c:449–523 | branch taken on the first frame, cg.time - trTime, whether the cap bit | extrapolation on first draw; F8 |
| First draw | CG_AddPacketEntities, cg_ents.c:641–644 | first frame that reaches the entity, and that frame's render time | \(R\), up to display latency |
| Ground truth | offline, from both players' eye positions logged every server frame during a candidate window | the crossing time and its reconstruction error | \(V\) and \(s_V\) |
Two rules about clocks. Use the server's snapshot time and sequence number as the shared key between server-side and client-side records; never join on wall clocks. And treat the client's offset from the server as a quantity to estimate, with the engine's own round-trip estimate as its prior, rather than as a known constant; box D6 shows where it enters the likelihood.
10.2 One event per corner crossing
Joined across the points above, one candidate becomes one record. The field names are mine; the point of showing the record is to make clear that the server half and the client half are stamped on different clocks and joined by sequence numbers.
{
"event_id": "match42:viewer7:target3:frame9181",
"server": {
"svs_time_ms": 182930,
"frame_ms": 50,
"k": 3,
"mode": "NETO_CULLED",
"check": "CULL_CLIP",
"gate": "projected_trace",
"trace_present": false,
"trace_projected": true,
"target_speed": 250.0,
"grace_until_ms": 183130,
"snapshot_seq": 4412,
"included": true,
"first_inclusion": true,
"nonpvs_sounds_last_1s": 3
},
"geometry": {
"map": "obj_team2",
"corner_id": "hash-of-local-geometry",
"approach_angle_deg": 12.5,
"closing_speed": 287.4,
"visibility_crossing_ms": 183041,
"crossing_error_ms": 4.0
},
"client": {
"packet_received_realtime_ms": 9928871,
"server_time_delta_ms": -9745940,
"cg_time_at_parse_ms": 182876,
"entity_first_current_cg_time_ms": 182985,
"first_position_branch": "trajectory_lerp",
"extrapolated_ms": 0,
"entity_first_rendered_cg_time_ms": 182985,
"had_previous_state": false,
"rendered_within_window": true
}
}
10.3 Model checks
Bayesian syntax does not make a model trustworthy. The following checks matter more than which sampler was used.
Prior-predictive checks. Simulate complete matches from the priors before fitting. Do they imply plausible medians, tail spikes and map-to-map variation? If the prior regularly produces ten-second delays, negative leads, or a lead above \(kf\), which the implementation cannot produce, fix the model before looking at data.
Posterior-predictive checks. Can data simulated from the fitted model reproduce the empirical delay histogram, its extreme quantiles, the correlation with round-trip time, per-client differences, corner-specific behaviour and the rate of never-rendered crossings? A model that matches the mean and misses the tails is worse than no model here, because the tails are where the decision lives.
Calibration and out-of-sample evaluation. Hold out whole clients, sessions and maps, not random events: random splits leak repeated corner and client information and flatter the model. Use leave-one-group-out or its Pareto-smoothed importance-sampling approximation [30], report interval coverage, and score predictions with a proper scoring rule. For the simulation studies that precede any real data, use simulation-based calibration.
Sensitivity. Repeat the decision under alternative priors, tail distributions, censoring assumptions, clock-offset errors and cost ratios. A recommendation that changes from \(k = 1\) to \(k = 5\) under modest changes of assumption is not a recommendation; it is evidence that more telemetry is needed, and Figure 6 is the picture of that evidence.
A publication rule I would hold myself to. Show the raw event distribution, the prior-predictive draws, the posterior-predictive draws and the decision curve on the same page. Readers should be able to see both the fit and the uncertainty without trusting the author.
Level 3Randomise \(k\) if you want a causal answer
Question: can telemetry collected under one fixed setting of \(k\) tell us what would happen under another?
Only through the structural assumption \(H = kf\varphi + \rho\), and only if \(\varphi\) does not itself depend on \(k\). The second part is not obviously true. Changing \(k\) changes which candidates become inclusions, and therefore which crossings appear in the data with a measured \(H\) at all; the realised fraction \(\varphi\) is observed only on crossings that were disclosed early enough to measure, so its distribution under \(k = 3\) is a selected sample of its distribution under \(k = 1\). The grace timer (section 5.7) adds a second dependence: with a longer horizon, more crossings begin inside a grace window opened by the previous encounter, and those have \(H\) larger than \(kf\) for a reason that has nothing to do with projection. Both effects bias an observational estimate of the effect of \(k\) in a direction that is hard to sign in advance.
The clean answer is an experiment. Assign \(k\) at random to short time blocks on each server, say fifteen minutes at a time, stratified by map, and log every candidate check during every block. Randomising per client would be unfair to the players in the disadvantaged arm and would also change the game they are playing against each other; randomising per server per block keeps every match internally consistent. The estimand is then the difference in the distributions of \(W\) and \(L\) between arms, and the model of box D6 becomes a way to borrow strength across blocks rather than the sole source of identification. A stepped-wedge design, in which servers switch from the current setting to a candidate one at staggered times, gives the same identification with a smaller change to what any one player experiences. In either case the fixture F4 asymmetry is worth a pre-registered check: the effect of \(k\) on the stationary-target arm should be zero, and a non-zero estimate there is a sign that something other than projection is moving.
12. Where this leaves me Level 1
I started this post to write down my own model of how a MOHAA server hides players and what that costs, and to check it against the code rather than against my memory of the code. What I ended up with is less a verdict than a vocabulary. There is no universal cutoff for how early a server should disclose a player, because the same setting is both too generous to the cheater with the best link and too stingy for the honest player with the worst one. But there is a universal structure. The disclosure lead \(H\), the delivery delay \(D_A\) and the render delay \(B\) fix the cheat's window \(W = [H - D_A]_+\) and the honest client's lateness \(L = [D_A + B - H]_+\); pushing \(H\) up buys less lateness with more early information, pushing it down buys the reverse, and inside the band where both are nonzero their sum is exactly \(B\). Everything else is a question about the distributions of those three quantities on a real population, and those are things to measure, not to argue about.
On that structure, OpenMoHAA at the revision I read sits at a defensible point. It filters per client rather than per PVS, it tests present line of sight first and projects both players three server frames ahead only when the present test fails, it keeps a released player visible for a 200 ms grace period, and it lets withheld players make themselves heard. My reading also found several places where the code does something other than what a comment or a name suggests, and I have tried to file each of them as an observation with a deterministic fixture attached rather than as a defect, because some of them will turn out to be intentional once someone who knows the history looks at them.
The Bayesian part is a proposal, not a result. What it would give a server operator is not a number but a sentence: given these clients, maps, corners, clocks and costs, here is what we believe will happen under each setting, and here is how uncertain we still are. Figures 5 and 6 are what that sentence looks like with simulated inputs; the point of section 10 is that the real inputs are logged at a dozen identifiable lines of code.
So this is where I would like help. If you know the OpenMoHAA or MOHAA source history better than I do, tell me where my reading is wrong or where an observation in section 8 is intentional. If you have a counterexample to the timing model, a corner that behaves differently from Figure 1 for a reason the model does not have room for, I want to see it. If you run a populated server and can log even a fraction of the event of section 10.2, I would rather fit a model to your data than keep simulating. And if you think the whole approach is wrong, I would like to hear that too, preferably with a measurement attached. The easiest way to reach me is on GitHub, as fecmtc. This post describes my current conceptual model of one codebase, chosen because it is the one I know best; it is not the project's documentation, it does not speak for the people who wrote the code, and it is offered in the hope of being corrected.
AI Disclaimer
I used AI in many ways while writing this blog post. Here is exactly how I used it: (i) I used Claude to help with the frontend (HTML/CSS/JS), including the interactive figures; (ii) I used Claude to read and cross-check source code and commit history at the pinned revisions, and to keep every excerpt byte-identical to the source it links to. Every claim about what the code does was checked by me against those excerpts, and the mistakes that remain are mine; (iii) I used Claude and GPT to dig deeper into the literature. I was familiar with most of the netcode material, but they helped me find some references I had not seen before, such as the behavioural-detection and cryptographic-fog work; (iv) I asked both Claude and GPT to proof-read the mathematics and the prose. They caught notation inconsistencies and helped me trim the derivations; (v) finally, I tried my best to write things myself, in my own words. Yet, some parts were edited by GPT and Claude for readability. Ultimately, the views and opinions expressed in this post are entirely my own.
References
In order of first appearance. Links checked as of September 2026. Source excerpts are pinned to openmohaa revision 667c50f9 (2026-04-13) and ioquake3 revision 58839361 (2026-07-19); the repositories' main branches have moved since and will keep moving.
- Jeff Yan, Brian Randell. A Systematic Classification of Cheating in Online Games. NetGames 2005.
- Lucas Demenais. A History of Anti-Cheat Techniques in Video Games, from Server-Side Code to Kernel Level. The Gistre Blog (EPITA), 2025.
- Seonghyun Park, Adil Ahmad, Byoungyoung Lee. BlackMirror: Preventing Wallhacks in 3D Online FPS Games. ACM CCS 2020.
- OpenMoHAA documentation. Configuration (section “Optimization / Antichams”),
docs/markdown/03-configuration/01-configuration.mdat 667c50f9; also published at docs.openmohaa.org. - Counter-Strike: Global Offensive server-side player occlusion (
sv_occlude_players), as described by third parties such as the CornerCullingSourceEngine README. I could not locate a first-party release note as of September 2026. - Paul Chamberlain. Demolishing Wallhacks with VALORANT’s Fog of War. Riot Games Technology Blog, 2020.
- Yahn W. Bernier. Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization. GDC 2001.
- Valve Developer Community. Source Multiplayer Networking.
- Gabriel Gambetta. Fast-Paced Multiplayer: Client-Server Game Architecture; Client-Side Prediction and Server Reconciliation; Entity Interpolation; Lag Compensation; and the live demo.
- J. M. P. van Waveren. The DOOM III Network Architecture. id Software, 2006.
- Peter Laurens, Richard F. Paige, Phillip J. Brooke, Howard Chivers. A Novel Approach to the Detection of Cheating in Multiplayer Online Games. ICECCS 2007, pp. 97–106.
- Dark Forest. Announcing Dark Forest. 2020.
- Ingonyama. Cryptographic Fog of War.
- Andrew Gelman, John B. Carlin, Hal S. Stern, David B. Dunson, Aki Vehtari, Donald B. Rubin. Bayesian Data Analysis, 3rd edition. Chapman & Hall/CRC, 2013.
- Richard McElreath. Statistical Rethinking, 2nd edition. Chapman & Hall/CRC, 2020.
- Riot Games. Riot’s Approach to Anti-Cheat. Riot Games Technology Blog, 2017.
- Fabien Sanglard. Quake 3 Source Code Review: Network Model. 2012.
- jfedor.org. Quake 3 Network Protocol.
- D. Stefyn, A. L. Cricenti, P. A. Branch. Quake III Arena Game Structures. CAIA Technical Report 110209A, Swinburne University of Technology, 2011.
- Neil “haste” Toronto. Unlagged: Quake 3 Networking Primer.
- Tariq10x. How a Software Feature Changed Online Gaming Forever. YouTube, 2024.
- Wikipedia. Potentially visible set.
- OpenMoHAA documentation. Differences from the original game (section “Non-PVS optimization”),
docs/markdown/01-intro/04-differences.mdat 667c50f9. - Samin Shahriar Tokey, Zesheng Chen, Colin Mettler, Dexuan Tang, Ben Boudaoud, Joohwan Kim, Josef Spjut, Mark Claypool. The Effects of Network Latency on the Peeker’s Advantage in First-person Shooter Games. FDG 2024 (project page).
- Yuhao Chen. Peeker’s Advantage in Asymmetrical Network Latency. Blog post, October 2025.
- Shengmei Liu, Xiaokun Xu, Mark Claypool. A Survey and Taxonomy of Latency Compensation Techniques for Network Computer Games. ACM Computing Surveys 54(11s), 2022.
- David Straily, Matt deWet. Peeking into VALORANT’s Netcode. Riot Games Technology Blog, 2020.
- Tariq10x. Subtick Just Sucks. YouTube, 2025.
- Tariq10x. I Built This to Scientifically Prove Why You Suck at FPS Games. YouTube, 2026.
- Aki Vehtari, Andrew Gelman, Jonah Gabry. Practical Bayesian model evaluation using leave-one-out cross-validation and WAIC. Statistics and Computing 27, 2017 (arXiv:1507.04544).
- Valve Developer Community. GoldSrc; Valve Software, halflife (Half-Life SDK repository).
- Tariq10x. Why 1999 Quake 3 Netcode Belongs in Every CS Degree. YouTube.