14.2 Client Prediction และ Lag Compensation

เฟส 14 · เน็ตเวิร์กและ Live-Service · เวลาเรียน: 20–35 h

ซ่อน latency ให้เล่นรู้สึกทันที — client-side prediction, server reconciliation และ server-side rewind เพื่อตรวจโดนอย่างยุติธรรม

บทที่แล้ว Multiplayer Architecture วางรากฐาน client-server model ไว้แล้ว: server เก็บสำเนาที่ถูกต้องที่สุดของโลกเกมไว้ชุดเดียว (เรียกว่า authoritative server) ฝั่ง client ส่ง input ของตัวเองไปให้ แล้ว server เป็นคนตัดสินว่าจริง ๆ แล้วเกิดอะไรขึ้น โมเดลนี้ถูกต้องตามหลักการ แต่ถ้าใช้ตรง ๆ แบบไม่มีอะไรเสริม จะเล่นแล้วรู้สึกแย่ ระหว่างจังหวะที่เรากดปุ่มกับจังหวะที่คำตอบจาก server มาถึงจอเรา มีดีเลย์จริง ๆ คั่นอยู่ วัดเป็นเวลาบนเครือข่าย ไม่ใช่จำนวนเฟรม บทนี้พูดถึงเทคนิคที่ใช้ซ่อนดีเลย์นั้น: การทำนายการเคลื่อนไหวของตัวเองไว้ล่วงหน้าก่อน server จะยืนยัน, การแก้ไขความผิดพลาดแบบเงียบ ๆ เมื่อ server ตัดสินไม่ตรงกับที่ทำนายไว้, การทำให้ผู้เล่นคนอื่นเคลื่อนไหวลื่นไม่กระตุกทั้งที่ข้อมูลมาไม่สม่ำเสมอ และสำหรับทุกอย่างที่เกี่ยวกับการยิงโดนผู้เล่นคนอื่น — การย้อนเวลาบน server เพื่อให้การยิงที่ดูแม่นบนจอคนยิงถูกตัดสินอย่างยุติธรรมด้วย

1. ปัญหาหลัก: รอ server ตอบแล้วรู้สึกหน่วง

สมมติ client กับ server ห่างกัน 50 มิลลิวินาทีในแต่ละทิศทาง — one-way latency (ดีเลย์ทางเดียว) 50ms ดังนั้น round trip (RTT, Round-Trip Time) จะประมาณ 100ms ถ้า client ไม่ทำอะไรนอกจากส่ง input แล้วรอคำตอบจาก server timeline ของการกดปุ่มครั้งเดียวจะเป็นแบบนี้:

CLIENT SERVER | | | t=0ms press "move forward" | |--- input packet ("move forward") ---------->| | | t=50ms packet arrives | | server moves the player | | and writes the new position |<--- position packet (x=1.2, z=0.0) --------| | t=100ms packet arrives, player finally moves | | |

ผู้เล่นต้องรอ 100ms หลังจากกดปุ่ม กว่าอะไรจะขยับบนจอ ต่อให้เน็ตบ้านดี ๆ ก็สังเกตได้แล้ว ถ้าเน็ตแย่กว่านี้ (RTT 200-300ms พบได้ทั่วไปบนเน็ตมือถือ) จะรู้สึกหน่วงมาก เหมือนบังคับเรือแทนที่จะบังคับตัวละคร และนี่คือกรณีที่ดีที่สุดแล้วด้วยซ้ำ — packet จริง ๆ ไม่ได้มาถึงห่างกันเท่า ๆ กัน (jitter อธิบายในหัวข้อ 5) ดังนั้นดีเลย์ไม่ได้คงที่ 100ms เป๊ะ ๆ แต่กระโดดขึ้นลง

server ต้องคง authority ไว้ — ถ้าปล่อยให้ client ตัดสินตำแหน่งของตัวเองได้จริง ๆ ทันที เกมนี้จะโกงง่ายมาก ดังนั้นทางแก้ไม่ใช่การ "สละ authority" แต่คือการทำให้จอของ client เองอัปเดตทันทีด้วยการเดา ในขณะที่ server ยังคำนวณคำตอบจริงอยู่เบื้องหลัง

Tip เกม single-player ไม่มีปัญหานี้เลย เพราะ input กับ simulation เกิดบนเครื่องเดียวกัน ในเฟรมเดียวกัน ทุกอย่างในบทนี้มีอยู่เพราะเครื่องสองเครื่องคุยกันผ่านเครือข่าย ไม่สามารถตกลงกันได้ทันทีว่า "เพิ่งเกิดอะไรขึ้น"

2. Client-Side Prediction: ทำก่อน ค่อยยืนยันทีหลัง

Client-side prediction คือการที่ client apply input ของตัวเองทันทีที่มันเกิดขึ้น โดยไม่รอ server ใช้กฎการเคลื่อนไหวเดียวกันเป๊ะกับที่ server จะใช้ในที่สุด ผู้เล่นเห็นตัวเองขยับทันที client กำลังเดาว่า server จะตัดสินอะไร — และเพราะทั้งสองฝั่งรันกฎเดียวกันบน input เดียวกัน การเดานี้จึงมักจะถูกเป๊ะแทบทุกครั้ง

CLIENT (predicts immediately) SERVER (confirms later) tick 10: apply input, screen moves NOW ---input seq=10--> (in flight) tick 11: apply input, screen moves NOW ---input seq=11--> (in flight) tick 12: apply input, screen moves NOW ---input seq=12--> (in flight) tick 10 processed <--ack seq=10, pos=..--- history now holds: [11, 12] (seq <= 10 will be dropped once its ack lands)

นี่คือ player controller แบบทำนายอย่างง่ายที่สุดใน Unity C# มัน apply การเคลื่อนไหวทันทีที่มี input เกิดขึ้น แล้วก็ส่ง input เดียวกันนั้นไปให้ server เพื่อให้ server รันกฎเดียวกันแบบ authoritative:

using UnityEngine;
using System.Collections.Generic;

public class PredictedInput
{
    public int Sequence;     // increasing counter, one per input this client has sent
    public Vector3 MoveDir;  // input direction for this tick
    public float DeltaTime;  // how much time this input covers
}

public class PredictedPlayer : NetworkBehaviour
{
    public float speed = 5f;
    int nextSequence = 0;

    // every unacknowledged input we have applied locally, oldest first
    Queue<PredictedInput> history = new Queue<PredictedInput>();

    void Update()
    {
        if (!IsOwner) return; // only the owning client predicts its own player

        Vector3 moveDir = new Vector3(
            Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));

        PredictedInput input = new PredictedInput
        {
            Sequence = nextSequence++,
            MoveDir = moveDir,
            DeltaTime = Time.deltaTime
        };

        ApplyMove(input);          // 1. move on screen right now, no waiting
        history.Enqueue(input);    // 2. remember it, in case the server disagrees
        SubmitInputServerRpc(input.Sequence, moveDir, input.DeltaTime); // 3. tell the server
    }

    void ApplyMove(PredictedInput input)
    {
        transform.position += input.MoveDir.normalized * speed * input.DeltaTime;
    }

    [ServerRpc]
    void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
    {
        // the server applies the SAME movement rule, authoritatively.
        // covered in full in section 3.
    }
}

สองอย่างนี้ทำให้มันเป็นการทำนายจริง ๆ ไม่ใช่แค่การเดาลอย ๆ ที่ไม่เกี่ยวข้องกับอะไร:

Tip prediction จะได้ผลเฉพาะกับสิ่งที่ client คำนวณได้แบบเดียวกับ server เท่านั้น การเคลื่อนไหว การกระโดด การชนแบบง่าย ๆ ทำนายได้ แต่การสุ่มไอเทม หรือ critical hit แบบสุ่ม ทำนายไม่ได้ — client ไม่รู้ว่า server กำลังจะสุ่มเลขอะไรออกมา การเดาจึงไม่มีประโยชน์ (เรื่องนี้จะกลับมาพูดอีกทีในหัวข้อ 9)

3. Server Reconciliation: snap แล้ว replay

server รับ SubmitInputServerRpc แต่ละครั้ง apply กฎการเคลื่อนไหวเดียวกัน แล้วจำ เลข sequence ของ input ล่าสุดที่ประมวลผลไปแล้ว ของผู้เล่นคนนั้นไว้ ตามตารางเวลาคงที่ (ครั้งละหนึ่ง tick — ขั้นตอน simulation แบบคงที่ ตามที่บท Multiplayer Architecture สอนไว้) มันจะส่งผลลัพธ์ authoritative กลับไปให้ client เจ้าของ: ตำแหน่งที่ได้ พร้อมเลข sequence นั้น

[ServerRpc]
void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
{
    transform.position += moveDir.normalized * speed * dt; // authoritative move
    lastProcessedSequence = sequence;

    // tell the owning client: "this is your real position, I have processed
    // everything up to and including this sequence number"
    StateUpdateClientRpc(transform.position, lastProcessedSequence);
}

ฝั่ง client StateUpdateClientRpc คือจุดที่ reconciliation เกิดขึ้น (การทำให้ state ที่ทำนายไว้กลับมาตรงกับ state ที่ authoritative อีกครั้ง) มีสามขั้นตอนทุกครั้งที่ state update มาถึง:

  1. Snap ตำแหน่ง local ไปยังตำแหน่ง authoritative ที่ server ส่งมา — ทิ้งการเดา เชื่อ server
  2. ทิ้ง input ทุกตัวใน history ที่มีเลข sequence น้อยกว่าหรือเท่ากับ sequence ที่ server ยืนยันแล้ว — server คิดตัวเหล่านั้นไปแล้ว ถ้า replay ซ้ำอีกจะเท่ากับ apply สองครั้ง
  3. Re-apply (replay) input ทุกตัวที่เหลือใน history ตามลำดับ บนตำแหน่งที่ snap ไปแล้ว — นั่นคือ input ที่ server ยังไม่เห็นตอนที่ส่ง update นี้มา
[ClientRpc]
void StateUpdateClientRpc(Vector3 serverPosition, int acknowledgedSequence)
{
    if (!IsOwner) return;

    // 1. snap to the authoritative position
    transform.position = serverPosition;

    // 2. drop every input the server has already processed
    while (history.Count > 0 && history.Peek().Sequence <= acknowledgedSequence)
        history.Dequeue();

    // 3. replay everything the server had not seen yet
    foreach (PredictedInput input in history)
        ApplyMove(input);
}

ถ้าการเดาของ client ตรงกับ server เป๊ะ ขั้นตอนที่ 3 จะ replay input บนตำแหน่งที่เหมือนกับที่ client เคยอยู่อยู่แล้ว — ผู้เล่นจะไม่ขยับให้เห็นตอนโค้ดนี้รัน เพราะ serverPosition บวก input ที่ replay กลับมาลงจุดเดิมเป๊ะที่จอเคยแสดงอยู่แล้ว ถ้าการเดาผิดไปนิดหน่อย (เช่นการชนที่ client ทำนายไม่ตรง) การ replay จะแก้ให้ มักจะเป็นการขยับเล็กน้อยระดับ sub-pixel ที่ไม่มีใครสังเกตเห็น

Common mistake ใช้ List<PredictedInput> แล้วเรียก RemoveAt(0) ในลูป ก็ทำงานได้แต่ช้าโดยไม่จำเป็น และการลบ item ระหว่าง iterate ด้วย foreach จะ throw exception ด้วย Queue<T> คือรูปแบบที่ถูกต้องตรงนี้: ลบจากหน้าคิว (Dequeue) เพิ่มที่ท้ายคิว (Enqueue) เท่านั้น

4. ไล่ทีละขั้นตอน: reconciliation trace

ใส่ตัวเลขจริงจะเห็นภาพชัดขึ้น สมมติผู้เล่นขยับแกนเดียว หนึ่งหน่วยต่อ tick เริ่มต้นช่วงนี้ที่ x = 10.0 client ทำนาย input ไปแล้วห้าตัว (sequence 41 ถึง 45) ก่อนได้ยินคำตอบจาก server:

history (client, before any server reply arrives): seq 41: +1 -> predicted x = 11 seq 42: +1 -> predicted x = 12 seq 43: +1 -> predicted x = 13 seq 44: +1 -> predicted x = 14 seq 45: +1 -> predicted x = 15 <- what the player currently sees server has only received and processed seq 41, 42, 43 so far (44 and 45 are still in flight, or the server just has not run its tick yet). server sends back: serverPosition = 13.0, acknowledgedSequence = 43

StateUpdateClientRpc ของ client รันตอนนี้:

step 1 snap: x = 13.0 (server's answer, trusted) step 2 drop acked: history = [44, 45] (41,42,43 <= 43, removed) step 3 replay: apply seq 44 (+1) -> x = 14.0 apply seq 45 (+1) -> x = 15.0 result: x = 15.0 -- IDENTICAL to what the screen already showed. nothing visibly moved. the prediction was correct, and reconciliation just quietly confirmed it.

ทีนี้มาดูกรณีที่น่าสนใจ: สมมติ tick 43 ข้ามสิ่งกีดขวางที่ client ไม่รู้จัก server จึงหยุดผู้เล่นจริง ๆ ที่ x = 12.6 แทนที่จะไปต่อถึง 13.0:

server sends back: serverPosition = 12.6, acknowledgedSequence = 43 step 1 snap: x = 12.6 step 2 drop acked: history = [44, 45] step 3 replay: apply seq 44 (+1) -> x = 13.6 apply seq 45 (+1) -> x = 14.6 result: x = 14.6 -- the player was showing 15.0 a moment ago, now shows 14.6. a 0.4 unit correction, applied once. over one frame that is usually a barely visible nudge rather than a snap (see the tip below for smoothing bigger corrections).
Tip การแก้ไม่กี่เซนติเมตรใน world space แทบจะมองไม่เห็นเลยที่ความเร็วเคลื่อนไหวปกติ การแก้ที่ใหญ่กว่านั้น (lag spike หรือการเทเลพอร์ตที่ server สั่ง) จะดูเหมือนกระโดด ทางแก้ที่ใช้กันบ่อยคือไม่ apply การแก้ไขทันที แต่ blend เข้าหามันทีละนิดในหลายเฟรม — เช่นใช้ Vector3.Lerp จากตำแหน่งที่แสดงอยู่ ไปยังตำแหน่งที่ reconcile แล้ว ในช่วง 100-150ms — แทนที่จะกำหนดค่าในขั้นตอนเดียว

5. Entity Interpolation: ทำให้ผู้เล่นคนอื่นลื่นไหล

prediction แก้ปัญหาการเคลื่อนไหวของตัวละครเราเอง เพราะเรารู้ input ของตัวเองก่อน server จะรู้ แต่มันช่วยตัวละครของผู้เล่นคนอื่นไม่ได้เลย — เราไม่มีทางรู้ว่าผู้เล่นคนอื่นกำลังจะกดอะไร สิ่งเดียวที่เราได้จากพวกเขาคือสตรีมของ snapshot (การอัปเดตตำแหน่ง) ที่ส่งมาผ่านเครือข่าย และสตรีมนั้นไม่เคยลื่นไหลสมบูรณ์แบบ packet ไม่ได้มาถึงในเวลาเท่ากันทุกครั้ง ความแปรปรวนของเวลาที่มาถึงเรียกว่า jitter

snapshots as the SERVER sent them, one every 50ms, perfectly even: t=100 t=150 t=200 t=250 t=300 the SAME snapshots as they actually ARRIVE at the client (jitter): t=132 t=151 t=244 t=248 t=301 (early) (late, a packet got delayed behind it)

ถ้า client แค่ snap ผู้เล่นคนอื่นไปยังตำแหน่งที่ snapshot ล่าสุดบอก ทันทีที่มันมาถึง ตัวละครจะขยับเป็นกระตุก ๆ ไม่สม่ำเสมอ — ลื่นบ้าง หยุดบ้าง แล้วก็มีสองอัปเดตมาซ้อนกันเกือบพร้อมกัน Entity interpolation แก้ปัญหานี้ด้วยการจงใจ render ผู้เล่นคนอื่นให้ช้ากว่าความเป็นจริงเล็กน้อย (ในอดีตเล็กน้อย) แล้ว blend ระหว่างสองตำแหน่งที่รู้แล้วอย่างลื่นไหล แทนที่จะกระโดดไปยังแต่ละตำแหน่งใหม่

client เก็บ buffer สั้น ๆ ของ snapshot ล่าสุดไม่กี่อันที่ได้รับ แต่ละอันแท็กด้วย timestamp ของ server มันเลือก render time ที่ช้ากว่า snapshot ล่าสุดนิดหน่อย — ปกติ 100-200ms มากพอที่จะมั่นใจได้ว่ามี snapshot อย่างน้อยสองอันคร่อมมันอยู่ แม้จะมี jitter — แล้วหาสอง snapshot ใน buffer ที่คร่อม render time นั้น แล้ว interpolate (blend) ระหว่างสองอันนั้น

buffer (server timestamp -> position): [ t=100, pos=2.0 ] [ t=150, pos=2.5 ] [ t=200, pos=3.4 ] render time chosen = latest arrival time - 120ms interpolation delay = t=170 t=170 falls between the t=150 and t=200 snapshots: t=150 -----------|------- t=170 -------|----------- t=200 pos=2.5 | render here | pos=3.4 | | fraction = (170-150)/(200-150) = 0.4 rendered pos = lerp(2.5, 3.4, 0.4) = 2.86
public struct Snapshot
{
    public float ServerTime;
    public Vector3 Position;
}

public class InterpolatedRemotePlayer : MonoBehaviour
{
    List<Snapshot> buffer = new List<Snapshot>();
    public float interpolationDelay = 0.12f; // seconds behind the newest snapshot

    public void OnSnapshotReceived(float serverTime, Vector3 pos)
    {
        buffer.Add(new Snapshot { ServerTime = serverTime, Position = pos });
        if (buffer.Count > 20) buffer.RemoveAt(0); // keep the buffer from growing forever
    }

    void Update()
    {
        if (buffer.Count < 2) return;

        float renderTime = buffer[buffer.Count - 1].ServerTime - interpolationDelay;

        // find the two snapshots that straddle renderTime
        for (int i = 0; i < buffer.Count - 1; i++)
        {
            Snapshot a = buffer[i];
            Snapshot b = buffer[i + 1];
            if (a.ServerTime <= renderTime && renderTime <= b.ServerTime)
            {
                float t = (renderTime - a.ServerTime) / (b.ServerTime - a.ServerTime);
                transform.position = Vector3.Lerp(a.Position, b.Position, t);
                return;
            }
        }
    }
}

ต้นทุนของมันคือดีเลย์ที่คงที่และจงใจให้เกิด — เราเห็นผู้เล่นคนอื่นช้ากว่าความเป็นจริงเสมอ 100-200ms นั่นเป็นการแลกเปลี่ยนที่คุ้มค่า: ดีเลย์เล็ก ๆ คงที่ที่มองไม่เห็น ดีกว่าการกระตุกที่เห็นชัด นี่ก็เป็นเหตุผลที่ lag compensation (หัวข้อ 7) ต้องมีอยู่ด้วย: ภาพจริงของ server ว่าทุกคนอยู่ตรงไหนตอนนี้ ไม่เหมือนกับสิ่งที่ client แต่ละตัวกำลังเห็นอยู่บนจอ

Tip interpolationDelay เป็นค่าที่ปรับได้ ไม่ใช่ค่าคงที่ตายตัว ถ้าเล็กเกินไป buffer จะหมดบ่อย ๆ บังคับให้ต้อง extrapolate (หัวข้อ 6) บ่อยกว่าที่ตั้งใจ ถ้าใหญ่เกินไป ผู้เล่นคนอื่นจะดูล้าหลังตำแหน่งจริงมากขึ้น ซึ่งสำคัญกว่าในเกมยิงจังหวะเร็ว มากกว่าในเกมทำฟาร์มจังหวะช้า

6. Extrapolation: เดาต่อเมื่อ buffer หมด

Interpolation ต้องการ snapshot ที่อยู่หลังระยะเวลาที่จะ render เพื่อใช้ blend เข้าหา ถ้าเครือข่ายสะดุด — packet หาย หรือ latency พุ่งขึ้นมา — snapshot ล่าสุดที่ client มีอาจเก่ากว่า render time ไปแล้ว โดยไม่มีอะไรใหม่กว่าให้ interpolate เข้าหา buffer หมด

buffer: [ t=150, pos=2.5 ] [ t=200, pos=3.4 ] <- nothing newer has arrived render time wanted = t=230 (past the newest snapshot: buffer ran dry)

Extrapolation (บางทีเรียก dead reckoning) คือทางเลือกสำรอง: แทนที่จะ blend ระหว่างสองจุดที่รู้แล้ว ก็ให้ตัวละครขยับต่อไปโดยใช้ velocity ล่าสุดที่รู้ เดาว่าตอนนี้มันน่าจะอยู่ตรงไหน

Vector3 lastPos = buffer[buffer.Count - 1].Position;
Vector3 velocity = EstimateVelocityFromLastTwoSnapshots(buffer);
float overrun = renderTime - buffer[buffer.Count - 1].ServerTime;

// guess: keep moving in the same direction at the same speed
Vector3 extrapolatedPos = lastPos + velocity * overrun;

Extrapolation ปลอดภัยแค่ในช่วงสั้น ๆ เท่านั้น — ไม่กี่สิบมิลลิวินาที ยิ่งปล่อยให้รันนานเท่าไรโดยไม่ตรวจสอบ ยิ่งเบี่ยงเบนจากความเป็นจริงมากขึ้นเท่านั้น: ผู้เล่นจริงเลี้ยว หยุด หรือกลับทิศตลอดเวลา และการ "ขยับทางเดิมต่อไป" จะยิ่งแย่ลงเรื่อย ๆ ยิ่งต้องเดาแบบมองไม่เห็นนานเท่าไร พอ snapshot จริงมาถึงในที่สุด การเดาแบบ extrapolate กับตำแหน่งจริงมักไม่ตรงกันเป๊ะ ตัวละครเลยต้องแก้ไขให้เห็นชัด ๆ — สแนปแบบเดียวกับที่ tip ในหัวข้อ 4 เตือนไว้ เพียงแต่คราวนี้เกิดกับตัวละครของคนอื่นแทนที่จะเป็นของเราเอง

Common mistake Extrapolate นานเป็นร้อยมิลลิวินาทีเพราะคิดว่า "ลื่นกว่าการหยุดนิ่งไว้เฉย ๆ" มันลื่นกว่าจริงแค่ชั่วครู่ แล้วก็จะแย่กว่าเดิม: ตัวละครวาร์ปกลับให้เห็นชัด ๆ ทันทีที่ข้อมูลจริงมาถึง เกมส่วนใหญ่จำกัด extrapolation ไว้ประมาณ 100-250ms แล้วก็แค่หยุดตัวละครนิ่ง (หรือทำให้จางลงนิดหน่อย) เกินกว่านั้น แทนที่จะเดาต่อไปเรื่อย ๆ ไม่มีที่สิ้นสุด

7. Lag Compensation: ย้อนเวลา server เพื่อตัดสินการยิงอย่างยุติธรรม

เอา prediction, reconciliation และ interpolation มารวมกัน แล้วดูว่าจากมุมมองของเรา ผู้เล่นแต่ละคนอยู่ตรงไหนในเวลาจริง:

ทีนี้ลองคิดถึงการเล็งยิง เราเล็งไปที่จุดที่เราเห็นศัตรู ซึ่งตามที่เพิ่งอธิบายไป ไม่ใช่จุดที่ศัตรูอยู่จริง ๆ บน server ตอนนี้ — แต่เป็นจุดที่ศัตรูเคยอยู่ เมื่อประมาณ 100 ถึง 300ms ที่แล้ว ขึ้นอยู่กับการเชื่อมต่อของเรา ถ้า server แค่เช็คว่า "มีใครยืนอยู่ตรงจุดที่ยิงเล็งไปไหม โดยใช้ตำแหน่ง authoritative ปัจจุบัน" การยิงที่ดูเล็งแม่นเป๊ะบนจอเรา มักจะพลาด เพราะ server ขยับศัตรูตัวนั้นไปข้างหน้าแล้วตั้งแต่ snapshot ที่เรากำลังดูอยู่

Lag compensation (เรียกอีกอย่างว่า server-side rewind) แก้ปัญหานี้โดยให้ server เก็บประวัติสั้น ๆ ของ hitbox (รูปทรงชนที่ใช้ตรวจจับการโดนยิง) ของทุกคนไว้หลาย tick — เช่นหนึ่งวินาทีล่าสุด เมื่อการยิงมาถึง server มันจะไม่เช็คกับตำแหน่งปัจจุบัน แต่จะคำนวณ latency ของคนยิง ย้อน hitbox ของผู้เล่นคนอื่นกลับไปยังจุดที่มันอยู่ ณ เวลานั้นในอดีต แล้วเช็คการยิงกับจุดนั้นแทน

server keeps a rolling history of hitboxes, one snapshot per tick: tick: 97 98 99 100 (=now, authoritative) victim x: 4.0 4.3 4.6 5.0 shooter's one-way latency (estimated from their ping) = ~66ms = about 2 ticks back shot arrives at the server tagged "fired at my local tick equivalent of server tick 98" server rewinds the VICTIM's hitbox to the tick-98 snapshot (x=4.3) instead of using the current tick-100 position (x=5.0), then checks the shot against x=4.3 -- the position the SHOOTER actually saw and aimed at.
bool ServerValidateHit(Player shooter, Ray shotRay, float shooterLatencySeconds)
{
    // 1. work out which point in the recent past the shooter was aiming at
    float rewindTime = ServerTime.Now - shooterLatencySeconds;

    foreach (Player target in AllPlayersExcept(shooter))
    {
        // 2. get that target's hitbox as it was back then, from the
        //    server's own rolling history buffer (same idea as
        //    the interpolation buffer in section 5, kept server-side)
        Hitbox rewoundHitbox = target.HitboxHistory.SampleAt(rewindTime);

        // 3. check the shot against the REWOUND hitbox, not the current one
        if (rewoundHitbox.IntersectsRay(shotRay))
        {
            ApplyDamage(target, shooter);
            return true;
        }
    }
    return false;
}

นี่คือเหตุผลที่ lag compensation ถูกอธิบายว่า "server ย้อนเวลากลับไปเพื่อตัดสินการยิงอย่างยุติธรรมให้คนยิง" จากมุมมองคนยิง การเล็งอย่างระมัดระวังแล้วลั่นไก จะโดนเป้าตามที่เห็นบนจอได้อย่างแม่นยำ ไม่ว่า ping จะเท่าไร ถ้าไม่มีระบบนี้ ผู้เล่นที่มี latency จริง ๆ จะต้อง lead การยิง (เล็งนำหน้า) ตลอดเวลาเพื่อชดเชยดีเลย์ที่มองไม่เห็นด้วยซ้ำ แล้วก็ยังจะพลาดการยิงที่ดูถูกต้องอยู่ดี

Common mistake เชื่อค่า latency ที่ client รายงานมาเอง client ที่โกงสามารถอ้าง latency ปลอมสูง ๆ เพื่อบังคับให้ server ย้อนเวลาไกลเกินความเป็นจริง ทำให้โดนเป้าที่หนีไปได้ทันเวลาแล้วในความจริง server ต้องใช้ RTT ที่ตัวเองวัดได้สำหรับการเชื่อมต่อนั้น ๆ และจำกัด (clamp) ไว้ไม่ให้เกิน rewind window สูงสุด (หัวข้อ 8) ไม่ว่า client จะอ้างอะไรมาก็ตาม

8. ข้อแลกเปลี่ยน: "ตายทั้งที่หลบหลังกำแพงแล้ว"

Lag compensation ทำให้ประสบการณ์ของคนยิงยุติธรรม โดยแลกกับประสบการณ์ของเหยื่อโดยตรง ลองไล่ดูการย้อนเวลาเดียวกันจากมุมมองเหยื่อบ้าง:

VICTIM's own screen (their prediction, up to date, section 2): tick 100 (now, locally): victim has already ducked behind the wall. victim's screen shows them safe. no bullet visible. no damage indicator. what the SERVER actually judged, using lag compensation: rewind target = tick 98 (the shooter's latency ago) at tick 98, the victim had NOT reached the wall yet shot intersects the tick-98 hitbox -> HIT CONFIRMED result delivered back to the victim, a moment later: "you took damage" -- even though, on the victim's own screen, they were already safely behind cover when the shot was fired.

นี่คือคำบ่นที่โด่งดังว่า "ตายทั้งที่หลบหลังกำแพงแล้ว" ในเกมยิงแข่งขันทุกเกมที่ใช้ lag compensation มันไม่ใช่บั๊ก — มันคือผลที่หลีกเลี่ยงไม่ได้โดยตรงจากการตัดสินการยิงให้ยุติธรรมกับคนยิง ต้องมีใครสักคนแพ้ในความไม่ตรงกันระหว่าง "จอของเหยื่อเองแสดงว่าอยู่ตรงไหน" กับ "จอของคนยิงแสดงว่าอยู่ตรงไหน" และ lag compensation จะตัดสินให้ฝั่งคนยิงชนะเสมอ เพราะทางเลือกตรงข้าม — เข้าข้างเหยื่อเสมอ — จะทำให้การยิงแทบเป็นไปไม่ได้เลยสำหรับใครก็ตามที่มี ping จริง

มีสองตัวแปรที่ควบคุมว่าจะรู้สึกแย่แค่ไหน และทั้งสองปรับได้โดยทีมที่ทำเกม:

ไม่มีการตั้งค่าไหนที่ทำให้ผู้เล่นทั้งสองฝ่ายรู้สึกว่าตัวเองเป็นผู้ตัดสินสิ่งที่เกิดขึ้นแต่เพียงผู้เดียวได้ — ดีเลย์เครือข่ายมีอยู่จริง และต้องมีใครสักคนรับผลนั้นไป lag compensation คือการเลือกอย่างจงใจว่าจะให้ใครรับ

9. เลือกว่าจะ predict อะไร (และไม่ predict อะไร)

Prediction ไม่ได้ฟรี — ทุก action ที่จะทำนายได้ ต้องให้ client รันกฎเดียวกับที่ server รัน และต้องมี path สำหรับ reconciliation เวลาที่มันผิด มันคุ้มค่าสำหรับ action ที่เกิดถี่และคำนวณใหม่ได้ถูก ๆ อย่างการเคลื่อนไหว แต่มันมักจะไม่คุ้มค่า หรือถึงขั้นผิดเลย สำหรับ action แบบอื่น:

Tip กฎง่าย ๆ ที่ใช้ได้: predict "ความรู้สึก" ของ action ให้ทันที (ปืนยิงให้เห็นทันทีที่กดคลิก) แต่ predict "ผลลัพธ์" ของ action (โดนไหม เสียหายเท่าไร) เฉพาะตอนที่ client คำนวณผลลัพธ์นั้นได้แบบเดียวกันเป๊ะกับที่ server จะคำนวณ

10. รวมทุกอย่างเข้าด้วยกัน: predicted movement loop แบบเต็ม

นี่คือตัวอย่างเดียวที่รวมหัวข้อ 2-4 เข้าด้วยกัน: local prediction, input history และ reconciliation เขียนเป็น player controller ตัวเดียว ใกล้เคียงกับที่โปรเจกต์ Unity NGO (Netcode for GameObjects) จริง ๆ จะเป็น เพียงแต่ทำให้ network call เรียบง่ายลงเพื่อให้อ่านง่าย:

using UnityEngine;
using System.Collections.Generic;

public class PredictedInput
{
    public int Sequence;
    public Vector3 MoveDir;
    public float DeltaTime;
}

public class FullPredictedPlayer : NetworkBehaviour
{
    public float speed = 5f;
    int nextSequence = 0;
    Queue<PredictedInput> history = new Queue<PredictedInput>();

    // ----- CLIENT SIDE -----

    void Update()
    {
        if (!IsOwner) return;

        Vector3 moveDir = new Vector3(
            Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));

        var input = new PredictedInput
        {
            Sequence = nextSequence++,
            MoveDir = moveDir,
            DeltaTime = Time.deltaTime
        };

        ApplyMove(input);
        history.Enqueue(input);
        SubmitInputServerRpc(input.Sequence, moveDir, input.DeltaTime);
    }

    [ClientRpc]
    void StateUpdateClientRpc(Vector3 serverPosition, int acknowledgedSequence)
    {
        if (!IsOwner) return;

        transform.position = serverPosition;

        while (history.Count > 0 && history.Peek().Sequence <= acknowledgedSequence)
            history.Dequeue();

        foreach (PredictedInput queued in history)
            ApplyMove(queued);
    }

    // ----- SHARED -----

    void ApplyMove(PredictedInput input)
    {
        transform.position += input.MoveDir.normalized * speed * input.DeltaTime;
    }

    // ----- SERVER SIDE -----

    int lastProcessedSequence = -1;

    [ServerRpc]
    void SubmitInputServerRpc(int sequence, Vector3 moveDir, float dt)
    {
        var input = new PredictedInput { Sequence = sequence, MoveDir = moveDir, DeltaTime = dt };
        ApplyMove(input); // same rule, authoritative result
        lastProcessedSequence = sequence;

        StateUpdateClientRpc(transform.position, lastProcessedSequence);
    }
}

ไล่ดูตั้งแต่ต้นจนจบสำหรับ lag spike หนึ่งครั้ง ใช้ speed = 1 unit/tick เพื่อให้ตัวเลขเข้าใจง่าย เริ่มที่ x = 0:

tick 1: client sends seq 1 (+1), predicts x=1, screen shows x=1 tick 2: client sends seq 2 (+1), predicts x=2, screen shows x=2 tick 3: client sends seq 3 (+1), predicts x=3, screen shows x=3 -- network hiccups here, nothing arrives back yet -- tick 4: client sends seq 4 (+1), predicts x=4, screen shows x=4 tick 5: client sends seq 5 (+1), predicts x=5, screen shows x=5 late server reply finally arrives: serverPosition=3.0, ackSeq=3 snap: x = 3.0 drop: history becomes [4, 5] (1,2,3 already acked) replay: apply seq 4 (+1) -> x=4.0 apply seq 5 (+1) -> x=5.0 screen shows x=5.0 both before AND after reconciliation. the lag spike delayed the SERVER's confirmation, but never touched what the PLAYER saw, because prediction had already moved them there.

นี่คือใจความสำคัญของครึ่งแรกของบทนี้ทั้งหมด ในหนึ่ง trace: ผู้เล่นไม่รู้สึกหน่วงเลยแม้แต่น้อย และ server ก็ไม่เคยเสีย authority ไป — มันแค่ยืนยันช้าไปนิดหน่อย สิ่งที่ client เดาไว้ถูกต้องอยู่แล้ว

11. Debug บั๊กของ prediction

บั๊กของ prediction และ reconciliation มักมีอาการเดียวกัน: ตัวละครสั่น กระตุก หรือค่อย ๆ เบี่ยงเบน แม้บนการเชื่อมต่อที่ดี สาเหตุที่เจอบ่อย ๆ มีดังนี้:

Tip ตอน debug ให้ log ขนาดของ history ทุกเฟรม บนการเชื่อมต่อที่เสถียร ค่านี้ควรอยู่แถว ๆ RTT / tickInterval และค่อนข้างคงที่ คิวที่โตขึ้นเรื่อย ๆ ไม่มีขีดจำกัด แปลว่า acknowledgment ไม่มาถึง หรือจับคู่กับเลข sequence ผิด — client กำลังทำนายล่วงหน้าไปเรื่อย ๆ โดยไม่มีอะไรมายืนยันเลย

12. Glossary

13. แบบฝึกหัด

แบบฝึกหัดที่ 1 — กำหนดขนาด Input Buffer เกมของคุณรัน simulation ที่ 30 tick ต่อวินาทีคงที่ (หนึ่ง tick ทุก ~33ms) และระบบจับคู่ผู้เล่นของคุณเล็งไว้ที่ผู้เล่นที่มี RTT สูงสุด 200ms ประมาณว่าจะมี PredictedInput กี่รายการที่อยู่ใน history พร้อมกันได้ ในกรณีที่แย่ที่สุด ก่อนที่ acknowledgment จะมาถึงได้เลย ทำไม client ถึงต้องเก็บอย่างน้อยจำนวนนั้นไว้ แทนที่จะเก็บแค่ตัวล่าสุดหนึ่งหรือสองตัว?
Show answer

หนึ่ง tick ประมาณ 33ms round trip เต็ม ๆ คือ 200ms ดังนั้น client จะส่ง input ที่ทำนายใหม่ไปเรื่อย ๆ ตลอด 200ms นั้น ก่อนที่ acknowledgment ตัวแรกสุดของ input ตัวไหนจะกลับมาได้เลย: 200ms / 33ms ต่อ tick ≈ 6 tick ในกรณีที่แย่ที่สุด client อาจมี input ที่ยังไม่ถูกยืนยันอยู่ใน history มากขนาดนั้นพร้อมกัน — บวกส่วนเผื่อเล็กน้อยสำหรับ jitter ดังนั้นปัดขึ้นเป็น 8-10 ช่อง ถือว่าปลอดภัย

ถ้า client เก็บแค่ input ล่าสุดหนึ่งหรือสองตัว reconciliation (หัวข้อ 3) จะไม่มีอะไรเหลือให้ replay หลัง snap ไปยังตำแหน่งของ server เลย — ทุก acknowledgment จะลบ input ที่ server ยังไม่ได้ประมวลผลจริง ๆ ทิ้งไป ผู้เล่นจะสแนปถอยหลังให้เห็นชัด ๆ ทุกครั้งที่มี update แทนที่จะอยู่นิ่ง ๆ history ต้องครอบคลุม round trip ทั้งหมด ไม่ใช่แค่ "สิ่งล่าสุดที่สุด"

แบบฝึกหัดที่ 2 — ไล่ trace ของ Reconciliation ผู้เล่นที่ถูกทำนายไว้เริ่มต้นช่วงนี้ที่ x = 50.0 client ส่งและ apply input ในเครื่องไปแล้วห้าตัว sequence 200 ถึง 204 แต่ละตัวขยับ +2 ตามแกน x server เนื่องจากมีการชนที่ client ไม่รู้จัก จริง ๆ แล้วขยับผู้เล่นแค่ +2 สำหรับ sequence 200 กับ 201 แล้ว +0 (ถูกกีดขวาง) สำหรับ sequence 202 จากนั้นรายงานกลับมาว่า serverPosition = 54.0, acknowledgedSequence = 202 ให้หาว่า: (a) จอของ client แสดงอะไรอยู่ก่อน update นี้จะมาถึง (b) เหลือ entry อะไรบ้างใน history หลัง reconciliation และ (c) จอแสดงตำแหน่งอะไรทันทีหลัง reconciliation
Show answer

(a) ก่อน update มาถึง client apply การขยับ +2 ทั้งห้าตัวไปแบบไม่รู้อิโหน่อิเหน่ บนตำแหน่ง 50.0: 50 + 2+2+2+2+2 = 60.0 นั่นคือสิ่งที่ผู้เล่นกำลังเห็นอยู่

(b) Reconciliation ทิ้งทุก entry ที่มี Sequence <= 202 ซึ่งลบ 200, 201 และ 202 ออกไป สิ่งที่เหลือใน history คือ [203, 204]

(c) Snap ไปยังคำตอบของ server ก่อน: x = 54.0 จากนั้น replay สอง input ที่เหลือ แต่ละตัว +2: 54 + 2 = 56 แล้ว 56 + 2 = 58 จอแสดง x = 58.0 หลัง reconciliation — เป็นการแก้ไขที่เห็นชัด 2 หน่วยถอยหลังจาก 60.0 ที่แสดงอยู่เมื่อครู่ เพราะการเดาของ client ไม่รู้จักการชนที่กีดขวาง sequence 202

แบบฝึกหัดที่ 3 — แก้บั๊กของ Interpolation เพื่อนร่วมทีมเขียนโค้ดนี้ไว้สำหรับ render ผู้เล่นระยะไกล ตอนทดสอบ ผู้เล่นคนอื่นขยับเป็นกระตุก ๆ เล็ก ๆ ให้เห็นชัด แม้บนการเชื่อมต่อที่เสถียรและแทบไม่มี jitter หาบั๊กแล้วแก้ให้ถูก
public class BuggyRemotePlayer : MonoBehaviour
{
    Vector3 latestPosition;

    public void OnSnapshotReceived(float serverTime, Vector3 pos)
    {
        latestPosition = pos;
    }

    void Update()
    {
        transform.position = latestPosition;
    }
}
Show answer

บั๊ก: โค้ดนี้ไม่เก็บ history เลย เก็บแค่ snapshot ล่าสุดตัวเดียว แล้ว snap ตรงไปยังมันทุกครั้งที่มีตัวใหม่มาถึง นี่คือพฤติกรรม "apply แค่ snapshot ล่าสุด" ที่หัวข้อ 5 อธิบายไว้ว่าทำให้เกิดการกระตุกไม่สม่ำเสมอเป๊ะ ๆ — ไม่มีอะไรให้ interpolate ระหว่างกลางเลย ดังนั้นระหว่างที่ยังไม่มี snapshot ใหม่มา ตัวละครจะหยุดนิ่ง แล้วก็เทเลพอร์ตทันทีที่ตัวถัดไปมาถึง

public class FixedRemotePlayer : MonoBehaviour
{
    struct Snapshot { public float ServerTime; public Vector3 Position; }

    List<Snapshot> buffer = new List<Snapshot>();
    public float interpolationDelay = 0.12f;

    public void OnSnapshotReceived(float serverTime, Vector3 pos)
    {
        buffer.Add(new Snapshot { ServerTime = serverTime, Position = pos });
        if (buffer.Count > 20) buffer.RemoveAt(0);
    }

    void Update()
    {
        if (buffer.Count < 2) return;

        float renderTime = buffer[buffer.Count - 1].ServerTime - interpolationDelay;

        for (int i = 0; i < buffer.Count - 1; i++)
        {
            if (buffer[i].ServerTime <= renderTime && renderTime <= buffer[i + 1].ServerTime)
            {
                float t = (renderTime - buffer[i].ServerTime) /
                          (buffer[i + 1].ServerTime - buffer[i].ServerTime);
                transform.position = Vector3.Lerp(buffer[i].Position, buffer[i + 1].Position, t);
                return;
            }
        }
    }
}

ทางแก้คือเก็บ buffer สั้น ๆ ของ snapshot แทนที่จะเก็บแค่ตัวเดียว เลือก render time ที่ช้ากว่าตัวล่าสุดที่มาถึงนิดหน่อย แล้ว blend (Lerp) ระหว่างสอง snapshot ที่คร่อมมันอยู่ทุกเฟรม — เป็นแพทเทิร์นเดียวกับหัวข้อ 5 เป๊ะ ซึ่งควรกลับไปอ่านซ้ำอีกทีตอนที่แบบฝึกหัดนี้เข้าใจแล้ว

← กลับไปหน้ารวมบท