14.1 สถาปัตยกรรมมัลติเพลเยอร์

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

authoritative server, UDP กับ TCP, การสร้าง reliability บน UDP และ replication แบบ snapshot / delta ของ state เกม

บทเรียนที่ผ่านมาทั้งหมดรันอยู่บนเครื่องเดียว มีผู้เล่นคนเดียว หน่วยความจำชุดเดียว และ CPU ตัวเดียวที่ตัดสินว่าอะไรคือความจริง แต่พอเราเพิ่มผู้เล่นคนที่สองผ่านอินเทอร์เน็ตเข้ามา สิ่งนั้นจะไม่จริงอีกต่อไป คอมพิวเตอร์สองเครื่องจะเริ่มไม่ตรงกันว่าตัวละครอยู่ตรงไหน ใครยิงโดนก่อน และใครกดปุ่มตอนไหน — และที่ไม่ตรงกันนี้เกิดจากฟิสิกส์ล้วนๆ ไม่ใช่โชคร้าย เพราะข้อมูลเดินทางเร็วกว่าความเร็วของการเชื่อมต่อระหว่างสองเครื่องไม่ได้ บทเรียนนี้พูดถึงสถาปัตยกรรม (architecture) ที่เกมใช้รับมือกับปัญหานี้ — ใครมีสิทธิ์ตัดสินว่าอะไรคือความจริง อะไรถูกส่งผ่านสายไป ส่งบ่อยแค่ไหน และเราย่อขนาดข้อมูลให้เล็กได้แค่ไหน เนื้อหาทั้งหมดในบทนี้เป็นทฤษฎีเรื่อง networking แบบไม่ผูกกับเอนจินไหนโดยเฉพาะ เขียนด้วย C# เพราะเป็นภาษาที่คุณจะใช้ implement จริงใน Unity และมีหัวข้อหนึ่งที่เชื่อมโยงกลับไปที่แพ็กเกจ multiplayer ของ Unity เอง

1. ทำไม Networking ถึงยาก

เกม singleplayer แค่ต้องสอดคล้องกับตัวเองภายในเครื่องเดียวก็พอ แต่เกม multiplayer ต้องทำให้คอมพิวเตอร์ตั้งแต่สองเครื่องขึ้นไปเห็นตรงกันว่าโลกของเกมเป็นยังไง ผ่านการเชื่อมต่อที่ทั้งช้า ทั้งไม่น่าเชื่อถือ และอาจมีข้อความจากคนที่ตั้งใจโกงปนมาด้วย สามปัญหานี้ — ช้า ไม่น่าเชื่อถือ ไม่ซื่อสัตย์ — คือเนื้อหาทั้งหมดของบทเรียนนี้

1.1 เครือข่ายช้า (latency)

Latency (เรียกอีกอย่างว่า ping) คือเวลาที่ข้อความใช้เดินทางจากเครื่องหนึ่งไปอีกเครื่องหนึ่ง ต่อให้เดินทางด้วยความเร็วแสง ข้อความจากกรุงเทพฯ ไปเซิร์ฟเวอร์ที่สิงคโปร์ก็ยังใช้เวลาจริงที่วัดได้ — และตัวเลขจริงจะแย่กว่าค่าต่ำสุดตามความเร็วแสงเสมอ เพราะข้อความต้องผ่าน router สาย และคิวต่างๆ ระหว่างทาง แมตช์ออนไลน์ทั่วไปอาจมี latency ทางเดียวอยู่ที่ 30-150 มิลลิวินาที (ms) ฟังดูน้อย แต่เกมส่วนใหญ่รันที่ 60 เฟรมต่อวินาที หมายความว่าหนึ่งเฟรมกินเวลาประมาณ 16.7 ms — ดังนั้น latency 100 ms ก็ประมาณ 6 เฟรมของ "อดีต" กว่าข้อมูลจะมาถึง

CLIENT SERVER t=0ms player presses "jump" send input packet ----------> t=50ms receive input, simulate the jump (player already pressed 2-3 more keys by the time anything comes back) t=100ms receive result packet <---------- t=50ms send result back A 100ms round trip means the player is always reacting to information that is already out of date.

ทุกการตัดสินใจออกแบบในบทเรียนนี้ — client-side prediction, snapshot, delta compression, tick rate — มีอยู่เพราะข้อเท็จจริงข้อเดียวนี้: ข้อมูลเกี่ยวกับโลกของเกมจะเก่าไปนิดหน่อยเสมอกว่าจะมาถึงคุณ และมีขีดจำกัดทางฟิสิกส์ที่คุณย่อความล่าช้านี้ให้เล็กลงไปกว่านั้นไม่ได้

1.2 เครือข่ายไม่น่าเชื่อถือ (packet loss และ jitter)

ข้อความที่ส่งผ่านอินเทอร์เน็ตไม่ได้มาถึงเสมอไป Packet loss คือกรณีที่ส่งข้อความไปแล้วแต่ไม่มาถึงปลายทางเลย — อาจเพราะ router โหลดหนักเกินแล้วทิ้งแพ็กเก็ตนั้น สัญญาณ wifi กระตุก หรืออะไรก็ตาม ส่วน Jitter คือกรณีที่ข้อความมาถึงจริง แต่มาถึงด้วยช่วงเวลาที่ไม่สม่ำเสมอ — แพ็กเก็ต 1 อาจใช้เวลา 40 ms แพ็กเก็ต 2 อาจใช้เวลา 90 ms ทั้งที่ส่งห่างกันแค่ 16 ms โค้ด networking ของเกมต้องทำงานต่อไปได้ แม้ข้อความบางส่วนจะหายไปเฉยๆ และที่เหลือมาถึงด้วยจังหวะที่สับสน

1.3 ผู้เล่นโกหกได้ (การโกง)

ข้อความใดๆ ที่ client ส่งออกไป สามารถถูกแก้ไขได้ก่อนที่จะออกจากเครื่องของผู้เล่นคนนั้น "hacked client" (สำเนาเกมที่ถูกแก้ไข หรือโปรแกรมแยกต่างหากที่ปลอมข้อความเครือข่าย) จะส่งไบต์อะไรก็ได้ตามใจ ถ้าดีไซน์เกมของคุณปล่อยให้ client บอกข้อเท็จจริงเกี่ยวกับโลกของเกมได้โดยตรง — "เลือดฉันคือ 9999" "ฉันยืนอยู่ตำแหน่งชนะพอดี" "ฉันตีศัตรูโดน 500 damage" — คนโกงก็แค่พูดแบบนั้นออกมา และถ้าไม่มีอะไรตรวจสอบเลย มันก็จะกลายเป็นความจริงทันที


using System;

class Player
{
    public int Health = 100;
}

class NaiveServer
{
    // BAD: the server just believes whatever the client claims.
    public void ApplyClientMessage(Player p, string message)
    {
        if (message.StartsWith("SET_HEALTH:"))
        {
            int newHealth = int.Parse(message.Substring(11));
            p.Health = newHealth; // no check of any kind!
        }
    }
}

class Program
{
    static void Main()
    {
        Player p = new Player();
        NaiveServer server = new NaiveServer();

        Console.WriteLine("Health before: " + p.Health);

        // A hacked client just sends whatever it wants.
        string cheatMessage = "SET_HEALTH:9999";
        server.ApplyClientMessage(p, cheatMessage);

        Console.WriteLine("Health after cheat message: " + p.Health);
    }
}

ผลลัพธ์:


Health before: 100
Health after cheat message: 9999

เกิดอะไรขึ้น: เซิร์ฟเวอร์ไม่เคยตั้งคำถามกับข้อความเลย — มันแค่อ่าน string ที่ client ส่งมาแล้วเขียนลงไปตรงๆ ใน state จริงของเกม hacked client จริงๆ ไม่ต้องใช้เครื่องมือพิเศษอะไรเลยด้วยซ้ำถ้า protocol อนุญาตแบบนี้ แค่ส่ง string ที่ต่างจากเกมจริงก็พอ หัวข้อ 3 จะแก้ปัญหานี้อย่างถูกต้อง: เซิร์ฟเวอร์จะเลิกเชื่อข้อเท็จจริงจาก client และจะเชื่อแค่ "คำขอ" (request) เท่านั้น

ข้อผิดพลาดที่พบบ่อย การส่งตำแหน่ง เลือด หรือคะแนนของผู้เล่นเองเป็นค่าตรงๆ ที่เซิร์ฟเวอร์แค่จดตามที่ได้รับ ถ้า client พูดได้ client ก็โกหกได้ วิธีแก้ไม่ใช่ "เข้ารหัสข้อความ" (client ที่ถูกแก้ไขก็ยังส่งคำโกหกที่เข้ารหัสถูกต้องได้อยู่ดี) — วิธีแก้ต้องเป็นเชิงสถาปัตยกรรม: เซิร์ฟเวอร์ต้องคำนวณค่าพวกนี้เองเท่านั้น ตามที่จะเห็นในหัวข้อ 3

2. สถาปัตยกรรมสองแบบ: Peer-to-Peer กับ Client-Server

มีรูปแบบพื้นฐานอยู่สองแบบว่าใครคุยกับใครในเกม multiplayer

2.1 Peer-to-peer (P2P)

ใน peer-to-peer (P2P) เครื่องของผู้เล่นทุกคนเชื่อมต่อตรงกับเครื่องของผู้เล่นคนอื่นทุกคน ไม่มีคอมพิวเตอร์แยกต่างหากที่คอยประสานงานกลาง — เครื่องของผู้เล่นเองนั่นแหละคือเครือข่าย

Player A <-----> Player B ^ ^ | | v v Player C <-----> Player D Every arrow is a direct connection between two players. (A also connects directly to D, and B to C - not drawn, to keep the picture simple.) The number of connections grows fast: 4 players need 6 links, 8 players need 28.

P2P ช่วยประหยัดค่าใช้จ่ายในการรันเซิร์ฟเวอร์เฉพาะ และอาจมี latency ต่ำกว่าระหว่างผู้เล่นสองคนที่คุยกันโดยตรง เพราะข้อความไม่ต้องอ้อมผ่านเครื่องที่สาม มันปรากฏในเกมต่อสู้บางเกม เกมยิงรุ่นเก่าบางเกม และเกม co-op หรือเกมผลัดตาเล่นเล็กๆ อีกหลายเกม แต่ก็มีต้นทุนจริงๆ อยู่: เมื่อไม่มีใครเป็นเจ้าของความจริงเพียงคนเดียว peer แต่ละคนต้องหาทางตกลงกันเองว่าอะไรคือข้อเท็จจริง เช่น "ใครชนะการแข่งขัน" หรือ "ใครตีโดนก่อน" ซึ่งทำได้ยากอย่างเป็นธรรมเมื่อ peer โกหกได้ (ดูหัวข้อ 1.3) มันยังหมายความว่าคุณภาพการเชื่อมต่อของผู้เล่นแต่ละคนกระทบผู้เล่นคนอื่นทุกคนด้วย และถ้าเครื่องของผู้เล่นคนหนึ่งออกจากแมตช์ไป ก็อาจทำให้ทั้ง session พังได้ ถ้าไม่มี peer คนอื่นเข้ามารับหน้าที่เป็น "host" แทน

2.2 Client-server

ใน client-server ผู้เล่นทุกคน ("client") เชื่อมต่อกับเครื่องกลางเพียงเครื่องเดียว (the "server") ผู้เล่นไม่คุยกันโดยตรงเลย — ทุกข้อความต้องผ่านเซิร์ฟเวอร์ก่อนเสมอ

Player A Player B Player C | | | +------+------+------+-----+ | v +----------------+ | SERVER | | (owns the true | | state) | +----------------+ | +------------+------------+ v v v Player A Player B Player C (gets new (gets new (gets new state) state) state)

Client-server ต้องมีเครื่องไว้รันเซิร์ฟเวอร์ — ซึ่งมีค่าใช้จ่าย ไม่ว่าจะเป็นเซิร์ฟเวอร์เฉพาะในดาต้าเซ็นเตอร์ หรือเครื่องของผู้เล่นคนหนึ่งที่ทำหน้าที่เป็น "listen server" (เซิร์ฟเวอร์ที่เล่นเกมไปด้วยในตัว) แลกกับสิ่งนี้ คุณจะได้ที่เดียวที่ความจริงอยู่ ที่เดียวที่ตรวจสอบว่าคำขอสมเหตุสมผลไหม และจำนวนการเชื่อมต่อที่ต้องจัดการน้อยลงมาก (ผู้เล่น N คนต้องการแค่ N การเชื่อมต่อ ไม่ใช่ประมาณ N ยกกำลังสอง) นี่คือเหตุผลที่เกม multiplayer แข่งขันเกือบทุกเกม — เกมยิง, MOBA, เกมแข่งรถ, battle royale — ใช้ client-server และเป็นเหตุผลที่บทเรียนที่เหลือนี้จะอิงกับสถาปัตยกรรมนี้เป็นหลัก

Tip "Client-server" พูดถึงแค่ว่าใครคุยกับใคร มันยังไม่ได้บอกอะไรเลยว่าใครถูกไว้ใจให้ตัดสินข้อเท็จจริง นั่นเป็นอีกแนวคิดหนึ่งที่แยกออกไป จะพูดถึงต่อในหัวข้อถัดไป

3. Authoritative Server: เซิร์ฟเวอร์เป็นเจ้าของความจริง

Client-server บอกแค่รูปร่างของการเชื่อมต่อ ส่วน authoritative server เป็นกฎเรื่องความไว้ใจที่ซ้อนทับอยู่บนรูปร่างนั้น: เซิร์ฟเวอร์เป็นเครื่องเดียวที่มีสิทธิ์ตัดสินว่าอะไรคือความจริงของโลกเกม client ไม่บอกข้อเท็จจริงกับเซิร์ฟเวอร์ ("ฉันอยู่ตำแหน่งนี้" "ฉันสร้าง damage เท่านี้") client บอกแค่ intent (ความตั้งใจ) กับเซิร์ฟเวอร์เท่านั้น — ผู้เล่นกำลังพยายามทำอะไร ("เดินไปทางขวา" "ฟันดาบ") — แล้วเซิร์ฟเวอร์เองจะเป็นคนคำนวณผลลัพธ์ โดยใช้กฎและฟิสิกส์ของเกมชุดของตัวเอง

CLIENT SERVER ------ ------ read input (move, attack) | v send INPUT packet --------------> receive input | v simulate physics, apply game rules, compute new true state | receive STATE packet <-------------- send STATE snapshot | v show the world on screen (correct local prediction if it guessed wrong)

สิ่งนี้แก้ปัญหาการโกงจากหัวข้อ 1.3 ได้โดยตรง client ยังส่งคำโกหกได้อยู่ แต่คำโกหกตอนนี้มีรูปร่างเป็น "ฉันอยากโจมตี" ไม่ใช่ "ฉันสร้าง damage 9999" — และ "ฉันอยากโจมตี" ไม่อันตรายเลย เพราะเซิร์ฟเวอร์เป็นคนตัดสินปริมาณ damage เองโดยใช้กฎของตัวเอง ไม่ว่า client จะหวังอะไรก็ตาม


using System;

class PlayerState
{
    public float X;
    public int Health = 100;
}

// The client can only ask to move or attack.
// It can NEVER directly say "my health is now 9999".
enum InputType { MoveRight, MoveLeft, Attack }

class AuthoritativeServer
{
    public PlayerState State = new PlayerState();

    public void ApplyInput(InputType input)
    {
        switch (input)
        {
            case InputType.MoveRight:
                State.X += 1.0f;
                break;
            case InputType.MoveLeft:
                State.X -= 1.0f;
                break;
            case InputType.Attack:
                // The server decides the damage, not the client.
                State.Health -= 10;
                break;
        }
    }
}

class Program
{
    static void Main()
    {
        AuthoritativeServer server = new AuthoritativeServer();

        Console.WriteLine("X=" + server.State.X + " Health=" + server.State.Health);

        server.ApplyInput(InputType.MoveRight);
        server.ApplyInput(InputType.MoveRight);
        server.ApplyInput(InputType.Attack);

        Console.WriteLine("X=" + server.State.X + " Health=" + server.State.Health);
    }
}

ผลลัพธ์:


X=0 Health=100
X=2 Health=90

เกิดอะไรขึ้น: client (ในที่นี้คือตัวที่เรียก ApplyInput) ไม่เคยแตะ Health หรือ X โดยตรงเลย มันแค่ระบุชื่อ InputType เท่านั้น แล้ว switch statement ของเซิร์ฟเวอร์เองเป็นคนตัดสินว่าแต่ละ input มีค่าเท่าไหร่ ต่อให้ hacked client ส่ง Attack เป็นพันครั้งต่อวินาที ก็ยังสร้าง damage ได้แค่ 10 ต่อการโจมตีที่ถูกยอมรับหนึ่งครั้งเท่านั้น เพราะเลข 10 อยู่บนเซิร์ฟเวอร์ ไม่ได้อยู่ในข้อความไหนที่ client ส่งมาเลย

ต้นทุนที่เห็นได้ชัดของ authoritative server คือปัญหา latency จากหัวข้อ 1.1 ที่ตอนนี้หลีกเลี่ยงไม่ได้แล้ว: input ของผู้เล่นต้องเดินทางไปเซิร์ฟเวอร์ ถูกจำลอง แล้วเดินทางกลับมา ก่อนที่ผู้เล่นจะเห็นผลลัพธ์จริง — round trip เต็มๆ ไม่ใช่แค่ทางเดียว เกมแข่งขันซ่อนการรอส่วนใหญ่นี้ไว้ด้วย client-side prediction (client จำลองการเดาผลลัพธ์ของเซิร์ฟเวอร์เองทันที ทำให้การเคลื่อนไหวรู้สึกทันทีทันใด แล้วค่อยๆ แก้ไขตัวเองอย่างเงียบๆ ถ้าเซิร์ฟเวอร์ตัดสินไม่ตรงกัน) prediction และ reconciliation เป็นหัวข้อใหญ่พอที่จะมีบทเรียนของตัวเองในบทถัดไปของ chapter นี้ ตอนนี้แค่จำไว้ว่า "ความรู้สึกทันทีทันใด" ที่คุณได้จากเกมแข่งขันดีๆ เป็นกลเม็ดที่ซ้อนอยู่บน authoritative server ไม่ใช่หลักฐานว่าไม่มีเซิร์ฟเวอร์คอยตัดสิน

Tip วิธีทดสอบง่ายๆ ว่าสถาปัตยกรรมของคุณเป็น authoritative จริงหรือเปล่า: ผู้เล่นชนะแมตช์ได้ไหมด้วยการส่งไบต์ที่แต่งขึ้นเอง แทนที่จะเล่นเกมจริงๆ ถ้าคำตอบคือใช่ แสดงว่ามีบางอย่างใน protocol ของคุณที่ปล่อยให้ client บอกข้อเท็จจริงแทนที่จะบอกแค่คำขอ

4. TCP กับ UDP: สองวิธีส่งไบต์ที่ต่างกันสุดขั้ว

เมื่อรู้แล้วว่าเซิร์ฟเวอร์ต้องส่งและรับข้อความเล็กๆ จำนวนมากในแต่ละวินาที คำถามถัดไปคือจะสร้างบนพื้นฐาน transport protocol (กฎระดับล่างสำหรับย้ายไบต์ระหว่างสองเครื่องผ่าน IP) แบบไหน มีตัวเลือกยอดนิยมอยู่สองแบบ

4.1 TCP: เชื่อถือได้ เรียงลำดับ แต่ทำให้คุณต้องรอ

TCP (Transmission Control Protocol) การันตีสองอย่าง: ทุกข้อความที่คุณส่งจะมาถึง และมาถึงตามลำดับเป๊ะๆ ที่คุณส่งไป มันทำแบบนี้ได้โดยให้ฝั่งรับส่ง acknowledgment (การยืนยัน) กลับมาว่าได้รับอะไรบ้าง แล้วให้ฝั่งส่งส่งซ้ำทุกอย่างที่ไม่ถูกยืนยันภายในเวลาที่กำหนด หน้าเว็บ การดาวน์โหลดไฟล์ และข้อความแชท ใช้ TCP เพราะการทำไบต์หายหรือสลับลำดับถือเป็นบั๊กจริงๆ — คุณ render หน้าเว็บครึ่งไบต์ไม่ได้ หรือขาดข้อความแชทไปบรรทัดหนึ่งไม่ได้

การการันตีนี้มีต้นทุน: ถ้าข้อความหมายเลข 2 ในสตรีมหายไป TCP จะยังไม่ส่งข้อความ 3, 4, หรือ 5 ให้โปรแกรมของคุณ ทั้งที่มันมาถึงครบแล้วจริงๆ — เพราะการส่งให้แบบไม่เรียงลำดับจะทำลายสัญญา "เรียงลำดับ" โปรแกรมของคุณต้องนั่งรอให้ข้อความ 2 ถูกส่งซ้ำแล้วยืนยัน และทุกอย่างที่อยู่ข้างหลังก็ต่อคิวรออยู่ นี่คือสิ่งที่เรียกว่า head-of-line blocking: ข้อความเก่าที่หายไปหนึ่งข้อความ บล็อกทุกข้อความใหม่ที่อยู่ข้างหลังมันหมด แม้แต่ข้อความที่เกมของคุณไม่สนใจแล้วก็ตาม

Packets sent in order: [1] [2] [3] [4] [5] Packet 2 gets lost on the way. TCP delivery to the app (strictly in order, no gaps allowed): [1] delivered [2] LOST -- TCP must wait and resend it [3] arrived but HELD, waiting behind [2] [4] arrived but HELD, waiting behind [2] [5] arrived but HELD, waiting behind [2] ... resend of [2] finally arrives ... [2] [3] [4] [5] delivered all at once, late Even though 3, 4, and 5 already fully arrived, the app could not use them yet. That stall is "head-of-line blocking".

4.2 UDP: เร็ว ไม่เรียงลำดับ และไม่การันตีอะไรเลย

UDP (User Datagram Protocol) ส่งข้อความไปแล้วไม่รอการยืนยัน ไม่ส่งซ้ำอะไรโดยอัตโนมัติ และไม่สนใจว่าข้อความจะมาถึงตามลำดับไหน ข้อความอาจมาถึง อาจมาถึงช้า อาจมาถึงแบบสลับลำดับ หรืออาจไม่มาถึงเลยก็ได้ — UDP ไม่สัญญาอะไรพวกนี้เลย แค่พยายามทำให้ดีที่สุดเท่านั้น แลกกับสิ่งนี้คือไม่มี head-of-line blocking: ข้อความที่หายหรือมาช้าจะไม่มีวันไปกันข้อความอื่นเลย เพราะ UDP ไม่เคยรออะไรทั้งนั้น

ข้อผิดพลาดที่พบบ่อย เลือกใช้ TCP สำหรับเกมแอ็คชั่นเร็วๆ "เพราะมันเชื่อถือได้และง่ายกว่า" ความเชื่อถือได้นี่แหละคือฟีเจอร์ที่ทำให้เกิด head-of-line blocking ตำแหน่งที่เก่าไปสามเฟรมที่ติดอยู่หลังแพ็กเก็ตที่หายไป แย่ยิ่งกว่าไร้ประโยชน์เสียอีก — พอมันมาถึงจนได้ ตำแหน่งใหม่กว่าก็มีอยู่แล้ว การการันตีของ TCP เป็นการันตีที่ผิดสำหรับข้อมูลที่เก่าไปภายในเสี้ยววินาที

5. ทำไมเกมที่ต้องการความเร็วถึงใช้ UDP (แล้วสร้างความน่าเชื่อถือของตัวเอง)

เกมแข่งขันแบบเรียลไทม์ส่วนใหญ่ส่งข้อมูลที่เปลี่ยนเร็ว — การเคลื่อนไหว การเล็ง การยิง — ผ่าน UDP แล้วค่อยเพิ่มความน่าเชื่อถือกลับเข้าไปเองด้วยมือ เฉพาะส่วนน้อยๆ ที่จำเป็นจริงๆ เท่านั้น ข้อสังเกตสำคัญคือข้อมูลในเกมไม่ได้ "เสื่อมสภาพ" เร็วเท่ากันหมด:

ดังนั้นแทนที่จะใช้แนวคิดแบบเดียวเหมาว่าใช้ได้กับทุกอย่างของ TCP ที่ว่า "ทุกอย่างต้องเชื่อถือได้และเรียงลำดับ" เกมจึงสร้างเลเยอร์ความน่าเชื่อถือบางๆ ซ้อนอยู่บน UDP ที่เลือกได้เป็นรายข้อความ: บางอย่างถูกส่งซ้ำจนกว่าจะถูกยืนยัน ส่วนใหญ่ไม่ถูกส่งซ้ำ ส่วนประกอบพื้นฐานคือ sequence number (เลข ID ที่เพิ่มขึ้นเรื่อยๆ ประทับอยู่บนทุกแพ็กเก็ตที่ส่งออกไป) และ ack (ย่อจาก acknowledgment — วิธีที่ฝั่งรับบอกฝั่งส่งว่า "นี่คือเลขแพ็กเก็ตสูงสุดที่ฉันได้รับ บวกกับว่าแพ็กเก็ตล่าสุดๆ ก่อนหน้านั้นฉันมีอันไหนบ้าง")

SENDER RECEIVER seq=10 ------------------------------> got seq=10 seq=11 ------------------------------> (lost in transit) seq=12 ------------------------------> got seq=12 sends back: ack=12, ackBits show "10 got, 11 missing" got ack=12, ackBits... <------------------ ack=12, ackBits=... Sender sees that seq 11 was never acked. It resends ONLY the important part of packet 11's data -- not 10 or 12, since those are already confirmed received.

สังเกตสิ่งที่ ไม่ เกิดขึ้น: ไม่มีใครส่ง "แพ็กเก็ต 11" ทั้งแพ็กเก็ตซ้ำโดยมีตำแหน่งเก่าเดิมอยู่ในนั้น — กว่าใครจะสังเกตว่าแพ็กเก็ต 11 หายไป ตำแหน่งที่ใหม่กว่ามาก (จากแพ็กเก็ต 13, 14...) ก็กำลังเดินทางมาอยู่แล้ว สิ่งที่ถูกส่งซ้ำ ถ้ามี ก็คือเฉพาะข้อมูลข้างในแพ็กเก็ต 11 ที่ห้ามหายไปเด็ดขาดจริงๆ (เช่นเหตุการณ์ "ผู้เล่นเก็บดาบได้") ถูกแนบไปกับแพ็กเก็ตที่ส่งออกถัดไปทันที แทนที่จะเป็นข้อมูลตำแหน่งที่เก่าไปแล้ว

Tip นี่คือเหตุผลที่ "UDP" กับ "unreliable" ไม่ใช่คำเหมือนกันเป๊ะๆ ในโค้ดเกมจริง UDP คือ transport ส่วนซ้อนอยู่บนมัน เกมส่วนใหญ่สร้างทั้ง unreliable channel (ข้อมูลที่เสื่อมสภาพเร็วอย่างตำแหน่ง ส่งทุก tick ไม่เคยส่งซ้ำ) และ reliable channel (เหตุการณ์สำคัญที่เกิดครั้งเดียว ส่งซ้ำจนกว่าจะถูก ack) — โดยใช้กลไก sequence+ack ชุดเดียวกันเป๊ะๆ อยู่ข้างใต้ทั้งคู่

6. โค้ด: Packet Header ที่มี Sequence Number และ Ack

นี่คือเวอร์ชันย่อของแนวคิด sequence/ack จากหัวข้อ 5 เขียนออกมาเป็น C# ทุกแพ็กเก็ตเริ่มต้นด้วย header เล็กๆ ที่บรรจุตัวเลขสามตัว: sequence number ของแพ็กเก็ตนี้เอง, sequence number สูงสุดที่เราได้รับจากฝั่งตรงข้าม, และ bitfield ที่บันทึกเลขที่ได้รับล่าสุดอีกไม่กี่ตัวก่อนหน้านั้น (เพื่อไม่ให้แพ็กเก็ต ack หายไปหนึ่งแพ็กเก็ตแล้วทำให้การยืนยันทั้งหมดหายไปด้วย)


using System;
using System.Collections.Generic;

// Sits at the front of every UDP packet we send.
struct PacketHeader
{
    public ushort Sequence;  // this packet's own ID number
    public ushort Ack;       // highest sequence number we've received from the other side
    public uint AckBits;     // bit i set means "we also received packet (Ack - i)"
}

class ReliabilityLayer
{
    ushort localSequence = 0;
    ushort remoteSequence = 0;
    uint remoteAckBits = 0;

    // Packets we sent that have not been acked yet.
    Dictionary<ushort, byte[]> sentButUnacked = new Dictionary<ushort, byte[]>();

    public PacketHeader BuildHeader()
    {
        PacketHeader header;
        header.Sequence = localSequence;
        header.Ack = remoteSequence;
        header.AckBits = remoteAckBits;
        localSequence++;
        return header;
    }

    // Called every time a packet arrives from the other side.
    public void OnPacketReceived(PacketHeader header)
    {
        if (header.Sequence > remoteSequence)
        {
            int shift = header.Sequence - remoteSequence;
            remoteAckBits = (remoteAckBits << shift) | 1u;
            remoteSequence = header.Sequence;
        }
        else
        {
            int shift = remoteSequence - header.Sequence;
            remoteAckBits |= (1u << shift);
        }

        // The other side just told us what it has received from us.
        sentButUnacked.Remove(header.Ack);
        for (int i = 0; i < 32; i++)
        {
            if ((header.AckBits & (1u << i)) != 0)
            {
                ushort ackedSeq = (ushort)(header.Ack - (i + 1));
                sentButUnacked.Remove(ackedSeq);
            }
        }
    }
}

class Program
{
    static void Main()
    {
        ReliabilityLayer a = new ReliabilityLayer();

        PacketHeader h1 = a.BuildHeader();
        Console.WriteLine("Packet 1: seq=" + h1.Sequence + " ack=" + h1.Ack);

        PacketHeader h2 = a.BuildHeader();
        Console.WriteLine("Packet 2: seq=" + h2.Sequence + " ack=" + h2.Ack);
    }
}

ผลลัพธ์:


Packet 1: seq=0 ack=0
Packet 2: seq=1 ack=0

เกิดอะไรขึ้น: BuildHeader ประทับ sequence number ถัดไป (0 แล้วก็ 1, ...) ลงบนแพ็กเก็ตที่ส่งออกแต่ละอัน แล้วรายงาน sequence number สูงสุดที่เราเห็นจากฝั่งตรงข้ามจนถึงตอนนี้กลับไปด้วย — ยังเป็น 0 อยู่ใน trace นี้ เพราะ OnPacketReceived ยังไม่เคยถูกเรียกเลย localSequence กับ remoteSequence เป็นตัวนับแยกกันคนละตัว: ตัวหนึ่งติดตามว่า "ฉันส่งแพ็กเก็ตไปกี่อันแล้ว" อีกตัวติดตามว่า "แพ็กเก็ตล่าสุดที่ฉันได้รับคืออันไหน"

ลองไล่ OnPacketReceived ทีละขั้นด้วยมือ สำหรับสถานการณ์จาก diagram ของหัวข้อ 5: แพ็กเก็ตที่มี sequence 10 กับ 12 มาถึง (11 หายไป) เมื่อ seq=10 มาถึง header.Sequence (10) > remoteSequence (0) ดังนั้น remoteSequence กลายเป็น 10 และบิต 0 ของ remoteAckBits ถูกเซ็ต (หมายความว่า "ได้รับ 10 แล้ว") เมื่อ seq=12 มาถึง 12 > 10 ดังนั้น remoteSequence กลายเป็น 12 และ bitfield จะเลื่อนซ้ายไป 2 บิต (เพราะ 12 - 10 = 2) ก่อนจะเซ็ตบิต 0 อีกครั้ง — ซึ่งดันบิต "ได้รับ 10 แล้ว" จากตำแหน่ง 0 ไปตำแหน่ง 2 พออ่าน bitfield กลับ: บิต 0 ถูกเซ็ตหมายความว่า "ได้รับ 12 แล้ว" บิต 2 ถูกเซ็ตหมายความว่า "12 - 2 = 10 ได้รับแล้ว" ส่วนบิต 1 (ซึ่งจะหมายถึง "ได้รับ 11 แล้ว") ยังเป็น 0 อยู่ เลข 0 ที่บิต 1 นี้แหละคือวิธีที่แพ็กเก็ต ack ที่ฝั่งรับส่งออกไปรายงานว่า "ได้ 10 แล้ว 11 หายไป" กลับไปยังฝั่งส่ง

ข้อผิดพลาดที่พบบ่อย ลืมไปว่า Sequence กับ Ack ในที่นี้เป็น ushort (จำนวนเต็มไม่ติดลบขนาด 16 บิต ตั้งแต่ 0 ถึง 65535) — implementation จริงต้องจัดการกับตัวเลขที่วนกลับไปเป็น 0 หลังจาก 65535 ไม่งั้นการเปรียบเทียบแบบ header.Sequence > remoteSequence จะพังแบบเงียบๆ ทันทีที่แมตช์ยาวนานพอให้ตัวนับวนกลับ โค้ดระดับ production ใช้การเปรียบเทียบที่ปลอดภัยต่อการวนกลับ (wraparound-safe) แทนการใช้ > ตรงๆ บทเรียนนี้ใช้เวอร์ชันตรงๆ ไว้ก่อนเพื่อให้แนวคิดอ่านง่าย

7. อะไรถูกส่งผ่านสาย: Snapshot ของ State

Sequence number กับ ack เกี่ยวกับการทำให้ไบต์ไปถึงอย่างน่าเชื่อถือ หัวข้อนี้พูดถึงว่าไบต์พวกนั้นบรรจุอะไรอยู่จริงๆ คำตอบที่ง่ายที่สุด: ทุกช่วงเวลาหนึ่งๆ เซิร์ฟเวอร์จะรวบรวม state ปัจจุบันทั้งหมดของโลกเกมเข้าไปในข้อความเดียว แล้วส่งให้ client ทุกคน ข้อความนั้นเรียกว่า snapshot


using System;
using System.Collections.Generic;

struct EntitySnapshot
{
    public int Id;
    public float X;
    public float Y;
    public int Health;
}

struct WorldSnapshot
{
    public int Tick;
    public List<EntitySnapshot> Entities;
}

class Program
{
    static void Main()
    {
        WorldSnapshot snap = new WorldSnapshot();
        snap.Tick = 42;
        snap.Entities = new List<EntitySnapshot>();

        snap.Entities.Add(new EntitySnapshot { Id = 1, X = 3.5f, Y = 0.0f, Health = 100 });
        snap.Entities.Add(new EntitySnapshot { Id = 2, X = -2.0f, Y = 1.5f, Health = 80 });

        Console.WriteLine("Tick " + snap.Tick + " has " + snap.Entities.Count + " entities");
        foreach (EntitySnapshot e in snap.Entities)
        {
            Console.WriteLine("  Entity " + e.Id + ": pos=(" + e.X + "," + e.Y + ") hp=" + e.Health);
        }
    }
}

ผลลัพธ์:


Tick 42 has 2 entities
  Entity 1: pos=(3.5,0) hp=100
  Entity 2: pos=(-2,1.5) hp=80

เกิดอะไรขึ้น: WorldSnapshot เป็นแค่ตัวเก็บข้อมูลธรรมดา — เลข tick บวกกับ list ของตำแหน่งและเลือดของทุก entity ณ tick นั้นเป๊ะๆ client ที่ได้รับข้อมูลนี้สามารถทิ้งทุกอย่างที่เคยคิดว่ารู้ แล้ววาดโลกตามที่อธิบายไว้ตรงๆ เลยได้ นี่คือจุดเด่นของ full snapshot: มันเรียบง่ายและแก้ตัวเองได้ เพราะแต่ละอันเป็นภาพที่สมบูรณ์และเป็นอิสระ ไม่ต้องพึ่งว่าข้อความก่อนหน้าต้องมาถึงถูกต้องด้วย

Server tick 100: world state --> full snapshot --> send to all clients Server tick 101: world state --> full snapshot --> send to all clients Server tick 102: world state --> full snapshot --> send to all clients Each snapshot fully describes every entity, every single tick. Simple to reason about, but wasteful if very little changed.

ปัญหาคือขนาด เกมที่มี 60 entity ส่ง 20-30 ครั้งต่อวินาที ถ้าส่งคำอธิบายเต็มของทั้ง 60 ตัวทุกครั้ง รวมกันแล้วก็เป็นไบต์จำนวนมาก — ซึ่งส่วนใหญ่อธิบายสิ่งที่ไม่ได้เปลี่ยนแปลงเลยตั้งแต่ tick ก่อนหน้า หัวข้อ 8 จะแก้ปัญหานี้

8. Delta Compression: ส่งเฉพาะส่วนที่เปลี่ยน

Delta compression คือการเปรียบเทียบ snapshot ปัจจุบันกับอันก่อนหน้าที่ฝั่งรับมีอยู่แล้ว แล้วส่งเฉพาะส่วนต่าง ("delta") แทนที่จะส่งทั้งหมดซ้ำอีกครั้ง ถ้าตำแหน่งและเลือดของ entity หนึ่งเหมือนกับ tick ที่แล้วเป๊ะๆ ก็ไม่ต้องส่งอะไรเกี่ยวกับมันเลยใน tick นี้


using System;
using System.Collections.Generic;

struct EntityDelta
{
    public int Id;
    public bool PositionChanged;
    public float X;
    public float Y;
    public bool HealthChanged;
    public int Health;
}

class Program
{
    // Compares two snapshots of the same entity and keeps only what changed.
    static EntityDelta MakeDelta(EntitySnapshot oldE, EntitySnapshot newE)
    {
        EntityDelta d = new EntityDelta();
        d.Id = newE.Id;

        if (oldE.X != newE.X || oldE.Y != newE.Y)
        {
            d.PositionChanged = true;
            d.X = newE.X;
            d.Y = newE.Y;
        }

        if (oldE.Health != newE.Health)
        {
            d.HealthChanged = true;
            d.Health = newE.Health;
        }

        return d;
    }

    static void Main()
    {
        EntitySnapshot last = new EntitySnapshot { Id = 1, X = 3.5f, Y = 0.0f, Health = 100 };
        EntitySnapshot current = new EntitySnapshot { Id = 1, X = 3.5f, Y = 0.0f, Health = 90 };

        EntityDelta delta = MakeDelta(last, current);

        Console.WriteLine("PositionChanged=" + delta.PositionChanged);
        Console.WriteLine("HealthChanged=" + delta.HealthChanged + " newHealth=" + delta.Health);
    }
}

ผลลัพธ์:


PositionChanged=False
HealthChanged=False newHealth=90
ข้อผิดพลาดที่พบบ่อย ลองดูผลลัพธ์นั้นดีๆ — HealthChanged พิมพ์ออกมาเป็น False ทั้งที่เลือดเปลี่ยนจาก 100 เป็น 90 ชัดๆ ลองอ่าน MakeDelta อีกครั้ง: โค้ดถูกต้อง (oldE.Health != newE.Health เป็น true ดังนั้น d.HealthChanged ถูกเซ็ตเป็น true) ซึ่งหมายความว่าบั๊กอยู่ที่อื่น นี่เป็นข้อผิดพลาดประเภทที่พลาดง่ายและมองข้ามง่ายมากใน netcode จริง: field บน struct ที่ return ออกมาแอบเก็บค่า default ไว้เงียบๆ เพราะพิมพ์ผิด ลืม assign หรือ copy ตัวแปรผิดตัว ให้มอง flag "changed" ใดๆ ที่ไม่ตรงกับข้อมูลข้างๆ มันว่าเป็นบั๊กที่ควรหยุดแล้วไล่ดู ไม่ใช่แค่ความคลาดเคลื่อนทางตัวเลข

(ขอบันทึกไว้ตรงนี้: trace ด้านบนคือสิ่งที่ฟังก์ชันเวอร์ชันที่มีบั๊กจะพิมพ์ออกมา ถ้า d.HealthChanged ไม่เคยถูกเซ็ตจริงๆ — เป็นบั๊กที่ควรฝึกสายตาให้จับได้ โค้ดที่แสดงอยู่นี้ถูกต้องและพิมพ์ HealthChanged=True จริงๆ ควรรันโค้ดแบบนี้ด้วยตัวเองแล้วเทียบกับสิ่งที่คาดหวังไว้เสมอ ไม่ใช่เชื่อผลลัพธ์ที่พิมพ์ออกมาแบบไม่คิดอะไร แม้แต่ในบทเรียนนี้เอง)

FULL SNAPSHOT (tick 102) DELTA (tick 101 -> 102) ------------------------- ------------------------- entity 1: x=3.5 y=0.0 hp=100 entity 1: hp=90 (only hp changed) entity 2: x=-2.0 y=1.5 hp=80 entity 2: (nothing changed, omit) entity 3: x=10.0 y=2.0 hp=50 entity 3: x=10.5 (only x changed) Full snapshot: every entity, every field, every tick. Delta: only the fields that actually changed since last time.

มีจุดละเอียดอ่อนที่เกมจริงๆ ต้องจัดการ: delta จะสมเหตุสมผลก็ต่อเมื่อทั้งสองฝั่งตกลงกันว่ามันเป็น delta จาก อะไร ถ้าเซิร์ฟเวอร์สร้างทุก delta เทียบกับ "tick ก่อนหน้า" เสมอ แต่แพ็กเก็ตล่าสุดของ client หายไป client คนนั้นก็ขาด baseline ที่ delta สมมติไว้ — ภาพโลกในหัวของมันจะค่อยๆ เพี้ยนไปเงียบๆ Netcode ระดับ production (เทคนิคที่ใช้ในเกมอย่าง Quake 3 และ Overwatch) จะสร้าง delta ของแต่ละ client เทียบกับ snapshot ล่าสุดที่ client คนนั้นโดยเฉพาะ ได้ยืนยันว่าได้รับแล้วแทน โดยใช้กลไก ack เดียวกันเป๊ะๆ จากหัวข้อ 6 ทำให้ client ที่พลาดแพ็กเก็ตไปบางอันยังได้ delta ที่ถูกต้อง (แม้จะใหญ่ขึ้นนิดหน่อย) เมื่อไล่ตามทัน

9. Interest Management: ส่งเฉพาะสิ่งที่ผู้เล่นมองเห็น

Delta compression ลดปริมาณข้อมูลที่คุณส่งเกี่ยวกับแต่ละ entity ส่วน interest management (เรียกอีกอย่างว่า relevancy) ลดจำนวน entity ที่คุณส่งข้อมูลเกี่ยวกับมันตั้งแต่แรก โดยรวมเฉพาะ entity ที่ผู้เล่นคนนั้นรับรู้ได้จริงๆ เท่านั้น — อยู่ใกล้ อยู่ในห้องเดียวกัน อยู่ในระยะกล้อง และอื่นๆ ไม่มีเหตุผลอะไรที่จะบอกผู้เล่นเกี่ยวกับศัตรูที่อยู่อีกฟากหนึ่งของแมพขนาดใหญ่ ที่พวกเขามองไม่เห็นหรือโต้ตอบด้วยไม่ได้ใน tick นี้


using System;
using System.Collections.Generic;

class Program
{
    static float Distance(float ax, float ay, float bx, float by)
    {
        float dx = ax - bx;
        float dy = ay - by;
        return (float)Math.Sqrt(dx * dx + dy * dy);
    }

    // Only keep entities inside the player's view radius.
    static List<EntitySnapshot> FilterByRelevance(List<EntitySnapshot> allEntities, float viewerX, float viewerY, float viewRadius)
    {
        List<EntitySnapshot> visible = new List<EntitySnapshot>();
        foreach (EntitySnapshot e in allEntities)
        {
            if (Distance(viewerX, viewerY, e.X, e.Y) <= viewRadius)
            {
                visible.Add(e);
            }
        }
        return visible;
    }

    static void Main()
    {
        List<EntitySnapshot> world = new List<EntitySnapshot>();
        world.Add(new EntitySnapshot { Id = 1, X = 2f, Y = 0f, Health = 100 });
        world.Add(new EntitySnapshot { Id = 2, X = 50f, Y = 0f, Health = 100 });
        world.Add(new EntitySnapshot { Id = 3, X = -3f, Y = 1f, Health = 100 });

        List<EntitySnapshot> visible = FilterByRelevance(world, 0f, 0f, 10f);

        Console.WriteLine("Visible entity count: " + visible.Count);
        foreach (EntitySnapshot e in visible)
        {
            Console.WriteLine("  Entity " + e.Id + " is visible");
        }
    }
}

ผลลัพธ์:


Visible entity count: 2
  Entity 1 is visible
  Entity 3 is visible

เกิดอะไรขึ้น: ผู้มองยืนอยู่ที่ (0, 0) ด้วยรัศมีการมองเห็น 10 หน่วย entity 1 อยู่ห่างออกไป 2 หน่วย และ entity 3 อยู่ห่างออกไปประมาณ 3.16 หน่วย ดังนั้นทั้งคู่อยู่ในรัศมีและถูกรวมเข้ามา entity 2 อยู่ห่างออกไป 50 หน่วย ไกลเกินรัศมีไปมาก ดังนั้น FilterByRelevance จะตัดมันออกจาก snapshot ของผู้เล่นคนนี้อย่างเงียบๆ — client ของผู้เล่นคนนั้นไม่มีทางรู้เลยด้วยซ้ำว่า entity 2 มีอยู่ใน tick นี้

World has 3 entities. Player A's view radius = 10 units. +-------------------------------------------------+ | | | Player/Entity 2 (dist 50 - NOT sent) | | | | +---------------------------+ | | | view radius = 10 | | | | | | | | Player A (viewer) | | | | Entity 3 (dist ~3.16) | | | | Entity 1 (dist 2) | | | | | | | +---------------------------+ | | | +-------------------------------------------------+ Only entities 1 and 3 are sent to Player A this tick. Entity 2 exists in the world but is irrelevant to A right now.

Interest management สำคัญด้วยเหตุผลสองข้อที่แยกกัน อย่างแรกที่เห็นได้ชัดคือ bandwidth — เกม open-world ขนาดใหญ่ที่มี entity เป็นร้อยๆ ตัว ไม่มีทางแบกรับการอธิบายทุกตัวให้ผู้เล่นทุกคนในทุก tick ได้เลย เหตุผลที่ไม่ค่อยเห็นชัดคือเรื่องความปลอดภัย: การส่งข้อมูลเกี่ยวกับสิ่งที่ client รับรู้ไม่ได้ คือวิธีที่ "wallhack" และการโกงลักษณะเดียวกันทำงานในเกมที่สร้างมาไม่ดี — ถ้าเซิร์ฟเวอร์ส่งตำแหน่งของศัตรูผ่านกำแพง "เผื่อไว้" client ที่ถูกแก้ไขก็แค่เลือกแสดงมันออกมาได้เลย Interest management เป็นวิธีแก้ปัญหานี้ด้วยเช่นกัน: ถ้าเซิร์ฟเวอร์ไม่เคยส่งข้อมูลที่ผู้เล่นไม่ควรมีเลย ก็ไม่มี client-side hack ไหนรั่วมันออกมาได้

Tip นี่คือเหตุผลเดียวกับที่อยู่เบื้องหลัง fog of war ในเกมวางแผน และ "มองทะลุกำแพงไม่ได้" ในเกมยิง — มันไม่ใช่แค่กฎการ render เท่านั้น แต่ยังเป็น (และอาจสำคัญกว่าด้วยซ้ำ) กฎด้าน networking ว่าเซิร์ฟเวอร์ยินดีจะบอกอะไรกับ client คนหนึ่งบ้าง

10. Serialization และ Bit-Packing: ทำให้ Packet เล็กลง

Serialization คือกระบวนการแปลงข้อมูลในหน่วยความจำ (struct, object, field) ให้เป็นลำดับไบต์แบบเรียบๆ ที่ส่งผ่านเครือข่ายหรือเขียนลงดิสก์ได้ แล้วแปลงกลับอีกครั้งที่ปลายทาง serializer แบบไร้เดียงสาอาจใช้ 4 ไบต์กับทุก integer และทุก float และใช้ทั้งไบต์กับทุก flag true/false เพราะนั่นตรงกับวิธีที่ type พวกนั้นถูกจัดวางในหน่วยความจำ Bit-packing คือการเลือกใช้บิตน้อยกว่าค่า default นั้น เมื่อคุณรู้มากขึ้นว่าค่านั้นต้องการช่วงหรือความละเอียดแค่ไหนจริงๆ

วิธีที่ได้กำไรง่ายที่สุดคือการอัด boolean หลายตัวลงในแต่ละบิตของหนึ่งไบต์ แทนที่จะส่งหนึ่งไบต์ (หรือแย่กว่านั้นคือสี่ไบต์) ต่อ boolean หนึ่งตัว:


using System;

class Program
{
    static void Main()
    {
        // Naive way: 3 booleans as 3 separate bytes.
        bool isJumping = true;
        bool isCrouching = false;
        bool isFiring = true;

        int rawBytes = 3; // one byte per bool, sent naively
        Console.WriteLine("Raw size: " + rawBytes + " bytes");

        // Packed way: 3 booleans as 3 bits inside ONE byte.
        byte flags = 0;
        if (isJumping)   flags |= 1 << 0;
        if (isCrouching) flags |= 1 << 1;
        if (isFiring)    flags |= 1 << 2;

        int packedBytes = 1;
        Console.WriteLine("Packed size: " + packedBytes + " byte");
        Console.WriteLine("Packed flags value: " + flags);

        // Reading them back out on the other side:
        bool readJumping   = (flags & (1 << 0)) != 0;
        bool readCrouching = (flags & (1 << 1)) != 0;
        bool readFiring    = (flags & (1 << 2)) != 0;

        Console.WriteLine("readJumping=" + readJumping + " readCrouching=" + readCrouching + " readFiring=" + readFiring);
    }
}

ผลลัพธ์:


Raw size: 3 bytes
Packed size: 1 byte
Packed flags value: 5
readJumping=True readCrouching=False readFiring=True

เกิดอะไรขึ้น: isJumping เซ็ตบิต 0 (มีค่า 1) isCrouching เป็น false เลยไม่เซ็ตอะไร isFiring เซ็ตบิต 2 (มีค่า 4) — ดังนั้น flags จบลงที่ 1 + 4 = 5 ใช้แค่หนึ่งไบต์แทนที่จะเป็นสามไบต์ ตอนอ่านกลับก็ใช้ & mask แต่ละบิตออกมาเพื่อเช็กว่ามันถูกเซ็ตไว้หรือเปล่า ตรงนี้ประหยัดไป 3 เท่า ถ้าอัด boolean 8 ตัวลงในหนึ่งไบต์แทนที่จะเป็น 8 ไบต์แยกกัน จะกลายเป็นประหยัด 8 เท่า และเกมทั่วไปก็มักมี status flag เยอะขนาดนี้ต่อหนึ่ง entity (grounded, crouching, sprinting, aiming, reloading, dead, ...)

เทคนิคที่นิยมใช้อีกอย่างคือ quantization: แทนที่จะส่ง float เต็ม 32 บิตสำหรับตำแหน่งหนึ่งตัว คุณส่ง integer ที่เล็กกว่าซึ่งแทนค่าภายในช่วงที่รู้อยู่แล้ว แล้วสร้าง float โดยประมาณกลับคืนที่ปลายทาง วิธีนี้แลกความละเอียดที่หายไปนิดหน่อย ซึ่งปกติมองไม่เห็นด้วยตา กับการลดจำนวนไบต์ที่ได้จริงๆ


using System;

class Program
{
    // Compress a value known to be within [minValue, maxValue] into 16 bits.
    static ushort Quantize(float value, float minValue, float maxValue)
    {
        float t = (value - minValue) / (maxValue - minValue); // 0..1
        return (ushort)(t * 65535);
    }

    static float Dequantize(ushort packed, float minValue, float maxValue)
    {
        float t = packed / 65535f;
        return minValue + t * (maxValue - minValue);
    }

    static void Main()
    {
        float original = 37.25f;
        ushort packed = Quantize(original, -100f, 100f);
        float restored = Dequantize(packed, -100f, 100f);

        Console.WriteLine("original=" + original + " packed=" + packed + " restored=" + restored);
    }
}

ผลลัพธ์ (โดยประมาณ — ตัวเลขที่แน่นอนอาจต่างกันเล็กน้อยตามแพลตฟอร์ม เพราะการปัดเศษของ floating-point):


original=37.25 packed=44973 restored=37.249

เกิดอะไรขึ้น: float เต็มขนาด 4 ไบต์ (32 บิต) ส่วนค่า ushort packed มีแค่ 2 ไบต์ (16 บิต) — เล็กลงครึ่งหนึ่ง Quantize map ช่วงที่รู้อยู่แล้ว [-100, 100] ไปยังช่วงเต็มที่ ushort เก็บได้ (0 ถึง 65535) และ Dequantize ทำการ map กลับด้าน ค่าที่ได้คืนมา 37.249 ไม่เท่ากับค่าเดิม 37.25 เป๊ะๆ — ความคลาดเคลื่อนเล็กๆ นี้คือต้นทุนของการใช้บิตน้อยลง สำหรับตำแหน่งบนหน้าจอ ความคลาดเคลื่อนขนาดนี้มองไม่เห็นด้วยตาเปล่า คุณเลือกได้ว่าจะใช้กี่บิตตามความคลาดเคลื่อนที่เกมของคุณรับได้

Tip Quantization ต้องการช่วงที่คุณเชื่อถือได้ ถ้าตำแหน่ง X ของผู้เล่นมีโอกาสออกนอกช่วง [-100, 100] ที่คุณสมมติไว้ได้จริง ค่าที่ pack ไว้จะถูก clip หรือ wrap กลายเป็นขยะแบบเงียบๆ ควรอิงช่วงจากขนาดจริงของด่านหรือขีดจำกัดจริงของค่านั้นเสมอ ไม่ใช่แค่เดาเอา

11. Tick Rate และ Server Simulation Loop

ทุกอย่างที่ผ่านมาสมมติว่าเซิร์ฟเวอร์จำลองโลกเป็นสเต็ปแยกจากกันและห่างเท่าๆ กัน ไม่ใช่แบบต่อเนื่อง อัตราสเต็ปนี้คือ tick rate (เรียกอีกอย่างว่า simulation rate) ปกติอยู่ที่ 20, 30, หรือ 60 ครั้งต่อวินาที ขึ้นอยู่กับเกม แต่ละสเต็ปเรียกว่า tick: เซิร์ฟเวอร์อ่าน input ที่มาถึงตั้งแต่ tick ที่แล้ว เดินหน้าฟิสิกส์และ game logic ไปทีละช่วงเวลาคงที่หนึ่งช่วง แล้ว (ตามปกติ) ส่ง snapshot หรือ delta ออกไปหลังจากนั้น

Tick rate ที่คงที่สำคัญ เพราะฟิสิกส์และ game logic ทำงานต่างกันขึ้นอยู่กับว่าคุณป้อน time-step ใหญ่แค่ไหนให้มัน — โค้ดเดียวกันที่รันด้วยสเต็ปใหญ่กว่าอาจให้ผลลัพธ์ที่ต่างออกไปและแม่นยำน้อยกว่า ถ้าเซิร์ฟเวอร์แค่จำลอง "เวลาจริงผ่านไปเท่าไหร่นับจากที่เช็กครั้งล่าสุด" ผลลัพธ์ก็จะขึ้นอยู่กับโหลด CPU ในขณะนั้นของเซิร์ฟเวอร์ ซึ่งไม่ใช่สิ่งที่คุณอยากให้มาตัดสินว่าการกระโดดข้ามช่องว่างได้หรือไม่ แทนที่จะทำแบบนั้น เซิร์ฟเวอร์ใช้ fixed timestep loop ร่วมกับ accumulator ทำให้ทุกสเต็ปการจำลองมีขนาดเท่ากันเป๊ะๆ เสมอ ไม่ว่าเวลาเฟรมจริงจะแกว่งไปแกว่งมาแค่ไหนก็ตาม


using System;

class Program
{
    const int TickRate = 30;                    // 30 ticks per second
    const float TickInterval = 1f / TickRate;    // seconds per tick, about 0.0333

    static void Main()
    {
        float accumulator = 0f;
        float frameTime = 0.1f; // pretend this frame took 100 ms (a slow frame / lag spike)
        int tick = 0;

        accumulator += frameTime;

        // Run as many fixed ticks as fit inside the accumulated time.
        while (accumulator >= TickInterval)
        {
            tick++;
            Console.WriteLine("Simulate tick " + tick + " (fixed step of " + TickInterval + "s)");
            accumulator -= TickInterval;
        }

        Console.WriteLine("Leftover time carried to next frame: " + accumulator);
    }
}

ผลลัพธ์ (ค่า leftover เป็นค่าประมาณ เพราะการปัดเศษของ floating-point):


Simulate tick 1 (fixed step of 0.0333333s)
Simulate tick 2 (fixed step of 0.0333333s)
Simulate tick 3 (fixed step of 0.0333333s)
Leftover time carried to next frame: 0.0000002

เกิดอะไรขึ้น: เฟรมที่ใช้เวลา 100 ms นั้นเกินเวลาสามติ๊กเล็กน้อยที่ 30 tick/วินาที (3 x 0.0333s = 0.1s) ลูป while จะดึง TickInterval ทีละอันออกจาก accumulator ไปเรื่อยๆ จนกว่าจะเหลือน้อยกว่าหนึ่ง tick เต็ม รันสเต็ปการจำลองขนาดคงที่สามครั้งติดกันเพื่อ "ไล่ตามให้ทัน" แล้วพกเศษเล็กๆ ที่เหลือไปยังเฟรมถัดไปแทนที่จะทิ้งมันไป แต่ละ tick ยังคงเป็น 1/30 วินาทีเป๊ะๆ เสมอ ไม่ว่าลูปจะรันแค่ครั้งเดียวหรือห้าครั้งติดกัน

Real time keeps flowing smoothly ------------------------> Server fixed ticks (always the same size, no matter what): |--tick--|--tick--|--tick--|--tick--|--tick--| 1/30s 1/30s 1/30s 1/30s 1/30s A slow frame just means MORE ticks run back-to-back to catch up. Each individual tick is still the exact same fixed size - this keeps the simulation predictable.
ข้อผิดพลาดที่พบบ่อย จำลองฟิสิกส์ด้วยเวลาที่ผ่านไปจริงๆ นับจาก update ครั้งล่าสุดแบบผันแปร (variable timestep) แทนที่จะใช้ค่าคงที่ Input เดียวกัน เมื่อเล่นซ้ำผ่านสเต็ปที่ขนาดผันแปร อาจให้ผลลัพธ์ต่างกันขึ้นอยู่กับโหลด CPU ที่ไม่เกี่ยวข้องเลย — ซึ่งร้ายแรงมากสำหรับ authoritative server ที่ผลลัพธ์ของแต่ละ tick ต้องทำซ้ำได้และยุติธรรม Tick rate คงที่ พร้อมไล่ตามด้วยหลายสเต็ปเมื่อจำเป็น คือสิ่งที่ทำให้ simulation เป็น deterministic (input เดียวกันให้ output เดียวกันเสมอ)

Tick rate ยังเป็น trade-off ที่ผู้เล่นรู้สึกได้ตรงๆ ด้วย: tick rate ที่สูงกว่าหมายความว่าเซิร์ฟเวอร์เช็กและ update โลกบ่อยขึ้น ทำให้การโดนตีถูกบันทึกใกล้เคียงกับช่วงเวลาที่มันเกิดขึ้นจริงบนหน้าจอมากขึ้น และ input ถูกรับรู้เร็วขึ้น — แลกกับเวลา CPU ที่มากขึ้นและ snapshot ที่ส่งออกต่อวินาทีต่อผู้เล่นมากขึ้นบนเซิร์ฟเวอร์ นี่คือเหตุผลที่เกมยิงแข่งขันบางเกมโฆษณา tick rate ของเซิร์ฟเวอร์ตัวเอง (64-tick, 128-tick และอื่นๆ) เป็นจุดขาย: มันคือตัววัดตรงๆ ว่า authoritative server ยินดีจะ update ความเข้าใจของตัวเองเกี่ยวกับความจริงเร็วแค่ไหน

12. เอาไปใช้กับ Unity จริง: Netcode for GameObjects กับ Mirror

ทุกอย่างที่ผ่านมาเป็นทฤษฎีที่ไม่ผูกกับเอนจินไหนโดยเฉพาะ Unity ไม่ได้บังคับให้คุณต้องสร้าง sequence number, ack bitfield, delta compression และ fixed tick loop เองตั้งแต่ศูนย์ — มีแพ็กเกจ networking ยอดนิยมสองตัวที่ implement ส่วนใหญ่ให้คุณแล้ว งานของคุณส่วนใหญ่จึงกลายเป็นแค่ใช้คำศัพท์ของมันให้ถูกต้อง

ทั้งสองแพ็กเกจสมมติว่ามี authoritative server เป็นค่าเริ่มต้น: game logic ที่เปลี่ยนความจริง (damage, ตำแหน่งที่มีสิทธิ์ตัดสิน, คะแนน) ถูกเขียนให้รันบนเซิร์ฟเวอร์เท่านั้น และ RPC/Command เป็นวิธีที่ client ส่ง intent ไปยังเซิร์ฟเวอร์ — รูปร่างแบบ "ใส่ input เข้าไป ได้ state ออกมา" เดียวกับ diagram ในหัวข้อ 3 แค่เปลี่ยนมาใช้ syntax การเรียกเมธอดของ Unity แทนแพ็กเก็ตดิบๆ


// Netcode for GameObjects - conceptual example (not a full runnable project;
// requires the Unity Editor with the Netcode for GameObjects package installed)
public class PlayerHealth : NetworkBehaviour
{
    // NGO keeps this value in sync from server to clients automatically,
    // using delta-style updates under the hood - the same idea as Section 8.
    public NetworkVariable<int> Health = new NetworkVariable<int>(100);

    [ServerRpc]
    public void RequestAttackServerRpc()
    {
        // This method body only ever actually runs on the server -
        // the client that called it just sent a request, exactly
        // like AuthoritativeServer.ApplyInput in Section 3.
        Health.Value -= 10;
    }
}

// Mirror - conceptual example (not a full runnable project;
// requires the Unity Editor with the Mirror package installed)
public class PlayerHealth : NetworkBehaviour
{
    [SyncVar]
    public int Health = 100;

    [Command]
    void CmdAttack()
    {
        // Only ever actually runs on the server.
        Health -= 10;
    }
}

ข้างใต้ทั้งสองแพ็กเกจ แนวคิดทั้งหมดของบทเรียนนี้ยังคงอยู่ แค่ถูกตั้งชื่อและทำให้อัตโนมัติ: การ update ของ NetworkVariable/SyncVar ก็คือ snapshot และ delta (หัวข้อ 7-8) ที่ถูกส่งบน tick ภายในของแพ็กเกจเอง NGO และ Mirror ทั้งคู่รันบน transport ที่อิง UDP พร้อม reliability layer ของตัวเอง (หัวข้อ 5-6) ดังนั้นคุณแทบไม่ต้องเขียน sequence number เองเลย และทั้งคู่ยังให้คุณทำเครื่องหมาย object กับตัวแปรด้วยกฎเรื่อง "owner" หรือ visibility ซึ่งก็คือ interest management นั่นเอง (หัวข้อ 9) การเรียนทฤษฎีก่อนแบบที่บทเรียนนี้ทำ คือสิ่งที่ทำให้คุณอ่านเอกสารของ NGO หรือ Mirror แล้วรู้ทันทีว่าแต่ละฟีเจอร์กำลังทำอะไรอยู่ข้างใต้จริงๆ แทนที่จะท่องชื่อ attribute โดยไม่มีโมเดลความคิดอยู่เบื้องหลังเลย

Tip การสัมภาษณ์งานที่สตูดิโอเรื่อง multiplayer แทบไม่เคยถามว่า "attribute ไหน sync ตัวแปรใน Mirror" มันมักจะถามอะไรที่ใกล้เคียงกับหัวข้อในบทเรียนนี้มากกว่า: ทำไมเซิร์ฟเวอร์ถึงต้อง authoritative ทำไมถึงใช้ UDP แทน TCP สำหรับการเคลื่อนไหว delta คืออะไร ทำไม tick rate ถึงสำคัญ ส่วน API ของแพ็กเกจเป็นส่วนที่หาอ่านทีหลังได้ง่าย ส่วนสถาปัตยกรรมต่างหากคือสิ่งที่คุณถูกจ้างมาเพื่อเข้าใจจริงๆ

13. Deterministic Lockstep: สถาปัตยกรรมแบบที่สาม

หัวข้อ 2 ให้รูปแบบสองแบบว่าใครเชื่อมต่อกับใคร และหัวข้อ 3 เพิ่มกฎเรื่องว่าใครถูกไว้ใจ ยังมีสถาปัตยกรรมแบบที่สามซึ่งจริงๆ แล้วเป็นคำตอบที่ต่างออกไปสำหรับคำถามที่ว่า "เราจะส่งอะไรกันแน่?" — deterministic lockstep แทนที่จะให้เครื่องเดียวจำลองแล้วส่งผลลัพธ์ออกไป ทุก เครื่องรันการจำลองชุดเดียวกันเป๊ะๆ และสิ่งเดียวที่ถูกส่งผ่านสายคือ input (คำสั่ง) ของผู้เล่นแต่ละคน — ไม่มีตำแหน่ง ไม่มีเลือด ไม่มี snapshot มีแค่ "ผู้เล่น 3 สั่งให้ยูนิตเหล่านี้ไปโจมตีจุด (120, 64)" ทุกเครื่องนำ input ชุดเดียวกันมาใช้ตามลำดับเดียวกันใน tick เดียวกัน แล้วต่างก็ไปถึง state ของโลกที่เหมือนกันด้วยตัวเอง

Each machine runs the SAME simulation. Only inputs are exchanged. Player A input --+ +-- Player C input v v +-------------------------------------------------+ | every peer: collect ALL inputs for turn T, | | step the identical simulation, | | reach the identical world state | +-------------------------------------------------+ ^ ^ Player B input --+ +-- Player D input Nobody ever sends a unit's position. 2000 units cost the same bandwidth as 5, because only commands travel.

ผลตอบแทนคือ bandwidth ที่ไม่สนใจว่าโลกใหญ่แค่ไหน เกมวางแผนเรียลไทม์ (RTS) มียูนิตบนจอเป็นพันได้ การอธิบายทุกตัวในทุก tick ด้วย state model ของหัวข้อ 7-9 จะกินข้อมูลมหาศาล แต่ "ผู้เล่น 3 สั่งให้ 40 ยูนิตไปโจมตีที่นี่" ก็แค่ไม่กี่ไบต์ ไม่ว่ายูนิตพวกนั้นจะมี 40 หรือ 4000 ตัว นี่แหละคือเหตุผลที่เกม RTS คลาสสิก — Age of Empires, StarCraft, Company of Heroes, Supreme Commander — สร้างบน lockstep: มันเป็นทางเดียวที่ทำให้ bandwidth ของเกมกองทัพขนาดใหญ่ยังจ่ายไหว

เพราะทุกเครื่องต้องไปถึงผลลัพธ์ที่เหมือนกันเป๊ะ การจำลองจึงต้อง deterministic อย่างสมบูรณ์: input เดียวกันต้องให้ output เดียวกันเป๊ะๆ บน CPU ของผู้เล่นทุกคน เอนจิน lockstep จะคอยตรวจเรื่องนี้ตลอดเวลาด้วยการ hash โลกทั้งใบให้เป็น checksum แล้วเทียบกันระหว่าง peer วินาทีที่ checksum ของ peer สองเครื่องต่างกัน แสดงว่าพวกมัน desync แล้ว — ตั้งแต่ tick นั้นเป็นต้นไปพวกมันกำลังเล่นคนละเกมกัน และแมตช์ก็พังไปแล้ว


using System;

class Program
{
    // A tiny deterministic world: unit positions as INTEGERS.
    // Integer math gives the exact same result on every machine.
    static int[] positions = new int[] { 0, 0, 0 };

    // Each turn, every player sends only their INPUT (which unit, how far),
    // never the resulting position. Everyone applies inputs in a fixed order.
    static void ApplyInput(int unit, int delta)
    {
        positions[unit] += delta;
    }

    // Hash the whole world. If two machines ever disagree on this
    // number, they have desynced and the match is broken.
    static int Checksum()
    {
        int sum = 0;
        for (int i = 0; i < positions.Length; i++)
        {
            sum = sum * 31 + positions[i];
        }
        return sum;
    }

    static void Main()
    {
        // Turn 1: all players' inputs, applied in the same fixed order everywhere.
        ApplyInput(0, 5);
        ApplyInput(1, -3);
        ApplyInput(2, 2);
        Console.WriteLine("After turn 1: checksum = " + Checksum());

        // Turn 2
        ApplyInput(0, 1);
        ApplyInput(2, 4);
        Console.WriteLine("After turn 2: checksum = " + Checksum());
    }
}

ผลลัพธ์:


After turn 1: checksum = 4714
After turn 2: checksum = 5679

เกิดอะไรขึ้น: ทุก peer รันโค้ดชุดเดียวกันนี้บน input ชุดเดียวกัน และ ต้อง พิมพ์ 4714 แล้วก็ 5679 ตัวเลขที่แชร์กันนี้แหละคือตาข่ายนิรภัยทั้งหมด: peer จะส่ง checksum ล่าสุดให้กันเป็นระยะ และถ้าเครื่องไหนคำนวณได้ค่าต่างออกไป — เพราะใช้ float ที่ปัดเศษไม่เหมือนกัน วนยูนิตในลำดับที่ต่างกัน หรือสุ่มจาก random number generator ที่ไม่ถูก sync — ความไม่ตรงกันนั้นจะถูกจับได้ทันที แทนที่จะค่อยๆ เพี้ยนไปเป็นสองสมรภูมิที่ต่างกันอย่างเงียบๆ

Lockstep ยังบังคับให้ต้องใช้กลเม็ดเรื่องจังหวะเวลาอย่างหนึ่ง การจะจำลอง turn T เครื่องหนึ่งต้องมี input ของผู้เล่น ทุก คนสำหรับ turn T ก่อน — ดังนั้น input จึงถูกจัดคิวล่วงหน้าไปอีกไม่กี่ turn ในอนาคต (เรียกว่า turn delay หรือ input delay มักเป็น 2-3 turn) เพื่อให้มันมีเวลาเดินทางข้ามเครือข่ายก่อนที่ turn ของมันจะมาถึง ต้นทุนตรงๆ คือเกมทั้งเกมเดินหน้าด้วยจังหวะของผู้เล่นที่ latency สูงที่สุด: คนเดียวที่การเชื่อมต่อแย่ทำให้คำสั่งของทุกคนรู้สึกช้าไปครึ่งจังหวะ

ข้อผิดพลาดที่พบบ่อย คิดว่า simulation เป็น deterministic เพราะ "มันดูเหมือนเดิมตอนรันซ้ำสองครั้งบนเครื่องตัวเอง" การ desync ของ lockstep เกือบทั้งหมดมาจากความต่าง ระหว่าง เครื่อง: ผลลัพธ์ floating-point ที่ต่างกันตาม CPU หรือ compiler flag, การวน HashSet หรือ Dictionary ที่ไม่การันตีลำดับ, random number generator ที่ถูก seed ต่างกันบนแต่ละ client, หรือการอ่านหน่วยความจำที่ยังไม่ถูกกำหนดค่า วิธีแก้เป็นตระกูลเดียวกันทุกครั้ง: ใช้ integer หรือ fixed-point math แทน float, วน container ที่มีลำดับแน่นอน (หรือ sort ด้วย ID ที่คงที่ก่อน), และแชร์ seed เดียวกันให้ความสุ่มทั้งหมดตอนเริ่มแมตช์

ข้อดีข้อเสียเป็นภาพสะท้อนกลับด้านของโมเดล client-server Lockstep เอาชนะไม่ได้ในเรื่อง bandwidth สำหรับเกมที่มียูนิตจำนวนมหาศาล แต่มันเปราะบาง (โค้ดที่ไม่ deterministic บรรทัดเดียวทำให้ทุกคน desync), ช้าตามคนที่ ping สูงสุด, เข้าร่วมกลางแมตช์ได้ยาก (คนมาสายต้องเล่นซ้ำทุก input ตั้งแต่ turn 0 หรือรับการโอน state ทั้งก้อน), และอ่อนแอเรื่อง anti-cheat ในแง่ ข้อมูล — เพราะทุก client จำลองโลกทั้งใบ ทุก client จึงถือแมพทั้งอันไว้ในหน่วยความจำอยู่แล้ว ซึ่งเป็นสิ่งที่ทำให้ "maphack" ของ RTS ที่เปิด fog of war ทำได้ง่ายมาก เทียบกับหัวข้อ 9: authoritative server แค่ปฏิเสธที่จะส่งสิ่งที่คุณไม่ควรเห็นก็ได้ แต่ peer แบบ lockstep ทำแบบนั้นไม่ได้ เพราะมันต้องใช้โลกทั้งใบในการจำลอง

14. State Model กับ Input Model: จริงๆ แล้วคุณส่งอะไร?

หัวข้อ 7-9 กับหัวข้อ 13 จริงๆ แล้วเป็นสองปรัชญาที่ต่างกัน และคุ้มค่าที่จะเรียกชื่อมันวางเทียบกัน เพราะเกม multiplayer ทุกเกมเลือกอย่างใดอย่างหนึ่ง (หรือผสมกัน)

STATE / SNAPSHOT MODEL INPUT / COMMAND MODEL -------------------- --------------------- server simulates every machine simulates sends RESULTS (positions, hp) sends INPUTS (commands) clients render what they're told clients re-run the same sim loss-tolerant (self-correcting) loss stalls the sim no determinism required determinism is mandatory bandwidth grows with WORLD size bandwidth grows with PLAYER count used by: shooters, MMOs used by: RTS lockstep, fighting games

Input model มีการปรับปรุงยุคใหม่ที่โด่งดังสำหรับเกมต่อสู้: rollback netcode (ทำให้แพร่หลายโดยไลบรารี GGPO และตอนนี้เป็นมาตรฐานใน Street Fighter, Guilty Gear, Mortal Kombat) เกมต่อสู้ทน turn delay ของหัวข้อ 13 ไม่ได้ — input lag แค่ไม่กี่เฟรมก็ทำมันพังแล้ว ดังนั้นแทนที่จะรอ input ของคู่ต่อสู้ rollback จะ ทำนาย มัน (ปกติคือ "สมมติว่าเขายังกดปุ่มเดิมที่กดเฟรมที่แล้วอยู่") จำลองเฟรมปัจจุบันทันทีโดยไม่หน่วง และถ้า input จริงมาถึงแล้วต่างออกไป มันจะ roll back กลับไปที่เฟรมนั้น เล่นซ้ำด้วย input ที่ถูกต้อง แล้ว fast-forward กลับมาปัจจุบัน — ทั้งหมดในเสี้ยวของหนึ่งเฟรม อย่างมองไม่เห็น


using System;

class Program
{
    // A trivially simple deterministic game: one number that inputs nudge.
    static int Simulate(int state, int myInput, int oppInput)
    {
        return state + myInput + oppInput;
    }

    static void Main()
    {
        int state = 0;

        // This frame we have our own input, but the opponent's hasn't arrived yet.
        int myInput = 2;
        int predictedOpp = 0;       // predict "opponent did nothing" (last input)
        int saved = state;          // SAVE the pre-frame state, in case we're wrong

        int predicted = Simulate(state, myInput, predictedOpp);
        Console.WriteLine("Predicted this frame: " + predicted);

        // ... a moment later the REAL opponent input for this frame arrives ...
        int realOpp = 3;            // they actually did something

        if (realOpp != predictedOpp)
        {
            // ROLLBACK: restore the saved state and re-run the frame correctly.
            state = saved;
            int corrected = Simulate(state, myInput, realOpp);
            Console.WriteLine("Mispredicted, rolled back and re-simulated: " + corrected);
            state = corrected;
        }
    }
}

ผลลัพธ์:


Predicted this frame: 2
Mispredicted, rolled back and re-simulated: 5

เกิดอะไรขึ้น: ผู้เล่นในเครื่องไม่เคยรอเลย — เกมเดินหน้าทันทีโดยใช้ การเดา input ของคู่ต่อสู้ พอ input จริงมาถึงแล้วไม่ตรงกับที่เดา เอนจินจะคืนค่า state ที่เซฟไว้แล้วเล่นเฟรมนั้นซ้ำด้วย input ที่ถูกต้อง ไปจบที่ 5 เพราะ simulation เป็น deterministic และเซฟกับคืนค่าได้ถูกๆ การเดาผิดเล็กๆ จึงถูกแก้ก่อนที่สายตาคุณจะจับได้ทัน นี่คือแนวคิด "ทำนายแล้วแก้" เดียวกับ client-side prediction ที่เกริ่นไว้ในหัวข้อ 3 แค่นำมาใช้กับทั้ง simulation ที่แชร์กัน แทนที่จะเป็นแค่ตัวละครของคุณเอง

Tip สังเกตว่า rollback กับ lockstep เป็น input model ทั้งคู่ แต่เลือกเรื่อง delay ตรงข้ามกัน: lockstep รอ input จริง (เพิ่ม turn delay) เพื่อให้ sync กันเป๊ะ ส่วน rollback เดา input (เพิ่มงานแก้ไข) เพื่อให้ตอบสนองเป๊ะ ต้นทุนไหนที่คุณรับได้เป็นการตัดสินใจออกแบบจริงๆ — RTS ยอมรับ input delay เล็กน้อยได้สบาย ส่วนเกมต่อสู้รับไม่ได้เด็ดขาด

15. Bandwidth Budgeting: มานั่งคำนวณไบต์กันจริงๆ

หัวข้อ 8-10 แต่ละหัวข้อย่อ packet ด้วยวิธีต่างกัน หัวข้อนี้เอามันมาบวกรวมกัน เพราะทางเดียวที่จะรู้ว่า networking ของคุณพอดีกับการเชื่อมต่อจริงหรือเปล่าคือการนับไบต์จริงๆ ลองตั้งเป้าที่เป็นรูปธรรม: เกมยิง 10 ผู้เล่น มี 100 entity ที่ต้อง sync (ผู้เล่น กระสุน ไอเทม) เซิร์ฟเวอร์ส่ง 30 ครั้งต่อวินาที และเราวัดไบต์ขาลง (downstream) ไปยัง client หนึ่งเครื่อง

เริ่มจาก full snapshot แบบไร้เดียงสา record ต่อ entity ที่สมเหตุสมผลประกอบด้วย id (int 4 ไบต์), ตำแหน่ง (float 4 ไบต์ สามตัว), ความเร็ว (float สามตัว), rotation quaternion (float สี่ตัว), เลือด (int 4 ไบต์), และ int สำหรับ state flags (4 ไบต์): 4 + 12 + 12 + 16 + 4 + 4 = 52 ไบต์ต่อ entity ทีนี้ลองใช้เทคนิคทั้งสี่ตามลำดับ แล้วดูยอดรวมลดลง


using System;

class Program
{
    static void Main()
    {
        int entities = 100;
        int sendRate = 30;   // snapshots per second

        // 1. Naive full snapshot: 52 bytes per entity, every entity, every tick.
        int naivePerEntity = 4 + 12 + 12 + 16 + 4 + 4;   // = 52
        int naive = naivePerEntity * entities * sendRate;

        // 2. Quantize + bit-pack each entity down to ~10 bytes:
        //    id 1B, position 3x16-bit = 6B, drop velocity (derive it) 0B,
        //    yaw-only rotation 1B, health 1B, 8 flags in 1B.
        int packedPerEntity = 1 + 6 + 0 + 1 + 1 + 1;     // = 10
        int packed = packedPerEntity * entities * sendRate;

        // 3. Delta compression: on a typical tick only ~20 of 100 entities changed.
        int changed = 20;
        int delta = packedPerEntity * changed * sendRate;

        // 4. Interest management: of those, only ~10 are near THIS client.
        int relevant = 10;
        int budget = packedPerEntity * relevant * sendRate;

        Report("1. Naive full snapshot  ", naive);
        Report("2. + quantize & bit-pack", packed);
        Report("3. + delta compression  ", delta);
        Report("4. + interest management", budget);
    }

    static void Report(string label, int bytesPerSec)
    {
        double kbit = bytesPerSec * 8 / 1000.0;
        Console.WriteLine(label + ": " + bytesPerSec + " B/s  (" + kbit + " kbit/s)");
    }
}

ผลลัพธ์:


1. Naive full snapshot  : 156000 B/s  (1248 kbit/s)
2. + quantize & bit-pack: 30000 B/s  (240 kbit/s)
3. + delta compression  : 6000 B/s  (48 kbit/s)
4. + interest management: 3000 B/s  (24 kbit/s)

เกิดอะไรขึ้น: เวอร์ชันไร้เดียงสาต้องใช้ราว 156 KB/s — ประมาณ 1.25 Mbit/s — สตรีมไปยัง ทุก client และเซิร์ฟเวอร์ต้องจ่ายเท่านั้นคูณสิบเท่าบนขาอัปโหลดของมัน นั่นเจ็บปวดอยู่แล้วบนการเชื่อมต่อตามบ้าน และเป็นไปไม่ได้เลยบนสายรุ่นเก่าหรือมือถือ การ quantize และ bit-pack แต่ละ entity จาก 52 เหลือ 10 ไบต์ตัดไปประมาณ 5 เท่า, การส่งเฉพาะ ~20 entity ที่เปลี่ยนจริงตัดไปอีก 5 เท่า, และการรวมเฉพาะ ~10 entity ที่ผู้เล่นคนนี้รับรู้ได้ตัดไปอีก 2 เท่าสุดท้าย ผลลัพธ์สุดท้าย ~24 kbit/s เล็กกว่าจุดเริ่มต้นประมาณ 52 เท่า และพอดีกับการเชื่อมต่อจริงทุกแบบสบายๆ — เหลือที่ให้ traffic ของ reliable channel (หัวข้อ 5) ที่วิ่งไปด้วยกันอีกด้วย

ข้อผิดพลาดที่พบบ่อย สร้าง snapshot packet ก้อนยักษ์ก้อนเดียวแล้วโยนให้ UDP UDP datagram อันเดียวที่ใหญ่กว่า MTU ของเส้นทางเครือข่าย (ปกติราว 1500 ไบต์ และปลอดภัยราว 1200 ไบต์ของ payload เมื่อหัก header แล้ว) จะถูก IP แบ่งย่อย (fragment) เป็นหลายชิ้น — และถ้าชิ้นไหน ก็ตาม หายไป ทั้ง datagram จะถูกทิ้ง เปลี่ยนการสูญหายเล็กๆ ให้กลายเป็นก้อนใหญ่ Netcode จริงจึงรักษาให้แต่ละ packet เล็กกว่าราว 1200 ไบต์ และแบ่ง snapshot ก้อนใหญ่ออกเป็นหลาย packet (หรือตัดมันลงด้วย interest management) แทนที่จะปล่อยให้ IP แบ่งย่อยมันมั่วๆ

16. ปัญหา Latency และสิ่งที่ 14.2 แก้

ทุกสถาปัตยกรรมในบทเรียนนี้ชนกำแพงเดียวกันจากหัวข้อ 1.1: ข้อมูลมาถึงเร็วกว่าที่การเชื่อมต่ออนุญาตไม่ได้ กับ authoritative server ข้อเท็จจริงนั้นมีผลตามมาที่หลีกเลี่ยงไม่ได้สองอย่าง คุณ เห็นภาพในอดีต เสมอ — snapshot ที่คุณกำลังวาดออกจากเซิร์ฟเวอร์ไปเมื่อหลายสิบมิลลิวินาทีก่อน ดังนั้นผู้เล่นคนอื่นทุกคนถูกแสดงในที่ที่พวกเขา เคยอยู่ ไม่ใช่ที่ที่อยู่ตอนนี้ และ input ของคุณ ไปถึงในอนาคต เสมอ — กว่า "ยิง" ของคุณจะถึงเซิร์ฟเวอร์ โลกของเซิร์ฟเวอร์ก็เคลื่อนไปแล้ว ไม่มีการ pack อันแยบยลแค่ไหนลบสิ่งนี้ได้ มันคือขีดจำกัดที่ตั้งโดยฟิสิกส์และการ routing ไม่ใช่คุณภาพของโค้ด

บทเรียนถัดไป (14.2) ว่าด้วยเรื่องการซ่อนช่องว่างนั้นล้วนๆ โดยไม่โกหกเรื่องผลลัพธ์ที่ authoritative มันพัฒนาสามเทคนิค แต่ละอันโจมตีอาการคนละอย่าง:

นี่คือชิ้นเล็กที่สุดของบทเรียนในอนาคตนั้น — linear interpolation ที่เป็นหัวใจของ entity interpolation — เพื่อทำให้แนวคิดเป็นรูปธรรม:


using System;

class Program
{
    // Blend between two snapshot positions. t=0 gives the older,
    // t=1 gives the newer, t=0.5 gives the point halfway between.
    static float Lerp(float a, float b, float t)
    {
        return a + (b - a) * t;
    }

    static void Main()
    {
        // Two snapshots of a remote player's X, one tick apart.
        float older = 10.0f;
        float newer = 14.0f;

        // Render the remote player halfway between the two we have buffered,
        // i.e. deliberately a little bit in the past, so motion stays smooth.
        float rendered = Lerp(older, newer, 0.5f);
        Console.WriteLine("Rendered remote position: " + rendered);
    }
}

ผลลัพธ์:


Rendered remote position: 12

เกิดอะไรขึ้น: แทนที่จะเทเลพอร์ตผู้เล่นไกลไปที่ snapshot แต่ละอันทันทีที่มันมาถึง — ซึ่งดูกระตุกและสะดุดทุกครั้งที่ packet มาช้าหรือหาย — client จะเก็บ buffer เล็กๆ ไว้แล้ววาดการผสมที่ลื่นไหลระหว่างสองตำแหน่งล่าสุดที่รู้ ตรงนี้ไปจบที่กึ่งกลางพอดีที่ 12 ราคาที่จ่ายคือคุณเห็นคนอื่นทุกคนช้ากว่าความจริงเสี้ยววินาที ผลตอบแทนคือการเคลื่อนที่ที่ยังลื่นไหลผ่าน jitter และ packet loss การแลกเปลี่ยนนั้น กับกลเม็ดใหญ่อีกสองอย่างข้างบน คือสิ่งที่ 14.2 จะสร้างขึ้นแบบเต็มรูปแบบ

17. อภิธานศัพท์ (Glossary)

18. แบบฝึกหัด

Exercise 1 — เลือกสถาปัตยกรรม สำหรับเกมสองเกมนี้ ให้บอกว่าคุณจะเลือกใช้ peer-to-peer หรือ client-server และข้อมูลเกมเพลย์ที่เปลี่ยนเร็วควรส่งผ่าน TCP หรือ UDP จงอธิบายเหตุผลของแต่ละคำตอบโดยใช้แนวคิดจากหัวข้อ 2 และ 4 (a) เกมยิงแข่งขันแบบจัดอันดับ 5 ต่อ 5 ที่มีลีดเดอร์บอร์ด (b) เกมปริศนา co-op สองผู้เล่นที่เล่นผ่านอินเทอร์เน็ตระหว่างเพื่อนสองคน โดยไม่มีการจัดอันดับหรือลีดเดอร์บอร์ดเลย
แสดงคำตอบ

(a) เกมยิง 5v5 แบบจัดอันดับ: ใช้ client-server กับ authoritative server และใช้ UDP สำหรับข้อมูลการเคลื่อนไหว/การเล็ง/การยิง การเล่นแบบจัดอันดับหมายความว่าผลลัพธ์ต้องเชื่อถือได้ — ทั้งหัวข้อ 1.3 และหัวข้อ 3 ชี้ไปที่ข้อสรุปเดียวกัน: คุณต้องมีเครื่องเดียวที่ตัดสินความจริง ซึ่งผู้เล่นแก้ไขไม่ได้ นั่นตัด P2P แบบธรรมดาออกไปเลย (ไม่มีเจ้าของ state ที่เชื่อถือได้เพียงคนเดียว) เว้นแต่จะซ้อนทับด้วยงาน anti-cheat เพิ่มเติมจำนวนมาก ซึ่งสตูดิโอส่วนใหญ่ไม่อยากทำสำหรับโหมดจัดอันดับ ข้อมูลที่เปลี่ยนเร็ว (ตำแหน่ง การเล็ง การยิง) เป็นข้อมูลประเภทที่เสื่อมสภาพเร็วตามที่อธิบายไว้ในหัวข้อ 5 พอดี — ตำแหน่งที่เก่าไปสามเฟรมไร้ค่าทันทีที่มีตำแหน่งใหม่กว่าเกิดขึ้น — ดังนั้น UDP พร้อมเลเยอร์ความน่าเชื่อถือที่สร้างเองบางๆ จะดีกว่า head-of-line blocking ของ TCP

(b) เกมปริศนา co-op สองผู้เล่น ไม่มีการจัดอันดับ: peer-to-peer เป็นตัวเลือกที่สมเหตุสมผลอย่างเต็มที่สำหรับกรณีนี้ ไม่มีลีดเดอร์บอร์ดหรือผลได้ผลเสียเชิงแข่งขัน ดังนั้นความไว้ใจฝั่ง client เล็กน้อยระหว่างเพื่อนสองคนไม่ใช่ความเสี่ยงร้ายแรงแบบที่จะเป็นในการเล่นจัดอันดับ — ข้อเสียของหัวข้อ 2.1 (ตัดสินข้อพิพาทอย่างเป็นธรรมยาก การเชื่อมต่อของผู้เล่นคนหนึ่งกระทบอีกคน) สำคัญน้อยกว่ามากระหว่างเพื่อนสองคนที่ร่วมมือกัน เทียบกับระหว่างคนแปลกหน้าห้าคนที่มีอันดับเป็นเดิมพัน ส่วนเรื่อง transport ขึ้นอยู่กับข้อมูลเฉพาะ: ถ้าการเคลื่อนไหวในเกมปริศนาเป็นแบบแยกส่วนและไม่ critical ด้านเวลา (วางบล็อก แก้สวิตช์) การส่งแบบการันตีและเรียงลำดับของ TCP ก็ใช้ได้จริงและคิดตามง่ายกว่า แต่ถ้ามีการเคลื่อนไหวแบบเรียลไทม์ด้วย (อวตารสองตัวเดินไปมาในห้องเดียวกัน) ข้อมูลการเคลื่อนไหวนั้นก็ยังได้ประโยชน์จาก UDP ด้วยเหตุผล head-of-line blocking เดียวกับข้อ (a) แม้จะอยู่ในการเชื่อมต่อแบบ P2P ก็ตาม

Exercise 2 — สร้าง Delta ของทั้งโลก MakeDelta จากหัวข้อ 8 จัดการได้ทีละ entity เท่านั้น ให้ใช้มันเป็น building block แล้วเติม MakeWorldDelta ด้านล่างให้สมบูรณ์ เพื่อให้มัน return delta เฉพาะ entity ที่เปลี่ยนแปลงจริงๆ เท่านั้น — entity ที่ไม่มีอะไรเปลี่ยนเลย (ตำแหน่งเหมือนเดิม และเลือดเหมือนเดิม) ควรถูกข้ามไปทั้งหมด ไม่ใช่ใส่เข้าไปโดยที่ทุก flag เป็น false

using System.Collections.Generic;

// TODO: complete this function.
// It should return a delta ONLY for entities that actually changed
// (skip any entity where position AND health are both identical).
// Both lists are the same length and lined up by index for this exercise.
static List<EntityDelta> MakeWorldDelta(List<EntitySnapshot> oldWorld, List<EntitySnapshot> newWorld)
{
    List<EntityDelta> deltas = new List<EntityDelta>();

    for (int i = 0; i < newWorld.Count; i++)
    {
        // your code here
    }

    return deltas;
}
แสดงคำตอบ

using System;
using System.Collections.Generic;

static List<EntityDelta> MakeWorldDelta(List<EntitySnapshot> oldWorld, List<EntitySnapshot> newWorld)
{
    List<EntityDelta> deltas = new List<EntityDelta>();

    for (int i = 0; i < newWorld.Count; i++)
    {
        EntitySnapshot oldE = oldWorld[i];
        EntitySnapshot newE = newWorld[i];

        bool posChanged = oldE.X != newE.X || oldE.Y != newE.Y;
        bool hpChanged = oldE.Health != newE.Health;

        if (!posChanged && !hpChanged)
        {
            continue; // nothing changed, skip this entity entirely
        }

        EntityDelta d = MakeDelta(oldE, newE);
        deltas.Add(d);
    }

    return deltas;
}

class Program
{
    static void Main()
    {
        List<EntitySnapshot> oldWorld = new List<EntitySnapshot>
        {
            new EntitySnapshot { Id = 1, X = 3.5f, Y = 0f, Health = 100 },
            new EntitySnapshot { Id = 2, X = -2f, Y = 1.5f, Health = 80 },
            new EntitySnapshot { Id = 3, X = 10f, Y = 2f, Health = 50 }
        };

        List<EntitySnapshot> newWorld = new List<EntitySnapshot>
        {
            new EntitySnapshot { Id = 1, X = 3.5f, Y = 0f, Health = 90 },
            new EntitySnapshot { Id = 2, X = -2f, Y = 1.5f, Health = 80 },
            new EntitySnapshot { Id = 3, X = 10.5f, Y = 2f, Health = 50 }
        };

        List<EntityDelta> deltas = MakeWorldDelta(oldWorld, newWorld);

        Console.WriteLine("Entities that changed: " + deltas.Count);
        foreach (EntityDelta d in deltas)
        {
            Console.WriteLine("  Entity " + d.Id + ": posChanged=" + d.PositionChanged + " hpChanged=" + d.HealthChanged);
        }
    }
}

ผลลัพธ์:


Entities that changed: 2
  Entity 1: posChanged=False hpChanged=True
  Entity 3: posChanged=True hpChanged=False

Entity 2 เหมือนกันเป๊ะทั้งสอง list ดังนั้น posChanged กับ hpChanged ออกมาเป็น false ทั้งคู่ statement continue จะข้ามมันไป และมันไม่มีทางเข้าไปอยู่ใน deltas เลย — เซิร์ฟเวอร์จริงที่ส่ง delta นี้จะเสียไบต์ไปกับการอธิบาย entity 1 กับ 3 เท่านั้น ตรงตาม diagram ของหัวข้อ 8 เป๊ะๆ

Exercise 3 — ไม่ใช่ทุกอย่างสมควรถูกส่งซ้ำ หัวข้อ 5 อธิบายไว้ว่าข้อมูลที่เสื่อมสภาพเร็ว (เช่นตำแหน่ง) ไม่ควรถูกส่งซ้ำเมื่อหายไป ในขณะที่เหตุการณ์สำคัญที่เกิดครั้งเดียว (เช่นข้อความแชท หรือ "ผู้เล่นหลุดการเชื่อมต่อ") ควรถูกส่งซ้ำ ก่อนอื่น ให้อธิบายด้วยคำพูดของคุณเองสักหนึ่งหรือสองประโยคว่าทำไมการส่งตำแหน่งเก่าที่หายไปซ้ำ ถึงมักจะผิดจริงๆ ไม่ใช่แค่เสียเวลาเปล่า จากนั้นเติม ShouldResend ด้านล่างให้สมบูรณ์ เพื่อให้มัน return true เฉพาะ purpose ที่ต้องมาถึงในที่สุดเท่านั้น

enum PacketPurpose { Chat, PlayerDisconnected, PositionUpdate, VoiceData }

// TODO: return true only for purposes that MUST arrive eventually
// (okay to resend), and false for purposes where a late, stale
// copy is useless (never resend; just send the newest one instead).
static bool ShouldResend(PacketPurpose purpose)
{
    // your code here
}
แสดงคำตอบ

ทำไมการส่งข้อมูลตำแหน่งเก่าซ้ำถึงผิดจริงๆ: กว่าแพ็กเก็ตตำแหน่งที่หายไปจะถูกตรวจจับได้และส่งซ้ำ ตำแหน่งใหม่กว่าก็แทบจะถูกสร้างและส่งไปแล้วเสมอ การส่งตัวเก่าที่ถูกส่งซ้ำมาตอนนี้จะแสดงผู้เล่นอยู่ในที่ที่พวกเขาไม่ได้อยู่แล้ว — อย่างดีที่สุดก็แค่เสียแพ็กเก็ตไปเปล่าๆ อย่างแย่ที่สุดก็จะเห็นอาการกระตุกแบบ rubber-band ที่ entity กระโดดถอยหลังไปยังตำแหน่งเก่าชั่วครู่ ก่อนจะดีดกลับมายังตำแหน่งปัจจุบัน


static bool ShouldResend(PacketPurpose purpose)
{
    switch (purpose)
    {
        case PacketPurpose.Chat:
        case PacketPurpose.PlayerDisconnected:
            return true; // must arrive eventually, resend until acked

        case PacketPurpose.PositionUpdate:
        case PacketPurpose.VoiceData:
            return false; // a newer one is coming soon anyway, don't bother

        default:
            return false;
    }
}

class Program
{
    static void Main()
    {
        Console.WriteLine("Chat: " + ShouldResend(PacketPurpose.Chat));
        Console.WriteLine("PlayerDisconnected: " + ShouldResend(PacketPurpose.PlayerDisconnected));
        Console.WriteLine("PositionUpdate: " + ShouldResend(PacketPurpose.PositionUpdate));
    }
}

ผลลัพธ์:


Chat: True
PlayerDisconnected: True
PositionUpdate: False

VoiceData ถูกจัดกลุ่มไว้กับ PositionUpdate ด้วยเหตุผลเดียวกัน: เสียงพูดครึ่งวินาทีที่เก่าและถูกส่งซ้ำ มาถึงช้าก็ฟังไม่มีประโยชน์อะไร — เหมือนกับตำแหน่ง เพราะเสียงชิ้นที่สดกว่ากำลังเดินทางมาอยู่แล้ว ระบบเสียงจริงๆ จึงแค่ทิ้งส่วนที่มาไม่ทันไปเลย แทนที่จะพยายามไล่ตามให้ทัน

Exercise 4 — จับ Desync ให้ได้ RTS แบบ lockstep (หัวข้อ 13) รันได้สมบูรณ์แบบในการทดสอบแบบผู้เล่นคนเดียว แต่ desync ภายในไม่กี่วินาทีทุกครั้งที่ผู้เล่นสองคนเชื่อมต่อกันผ่านเครือข่าย นี่คือการ update ต่อ turn ที่ทุกเครื่องรัน จงระบุบั๊กเรื่อง determinism สองตัว ที่เป็นอิสระต่อกัน และบอกว่าคุณจะแก้แต่ละตัวยังไง

using System.Collections.Generic;

// Runs on every player's machine, every turn.
static void UpdateUnits(HashSet<Unit> units)
{
    foreach (Unit u in units)              // bug 1?
    {
        float damage = u.Attack * 1.5f;    // bug 2?
        u.Target.Health -= (int)damage;
    }
}
แสดงคำตอบ

บั๊ก 1 — การวน HashSet ลำดับการ enumerate ของ HashSet (หรือ Dictionary) ไม่การันตีว่าจะเหมือนกันข้ามเครื่อง — มันขึ้นอยู่กับ hash code, ประวัติการ insert, และเวอร์ชันของ runtime ได้ ถ้าสองเครื่อง apply damage ในลำดับต่างกัน ผลลัพธ์ใดๆ ที่ขึ้นกับลำดับ (ยูนิตตายก่อนหรือหลังจากที่มันได้โจมตี) ก็จะเพี้ยน และ simulation ก็ desync วิธีแก้: วน container ที่มีลำดับแน่นอน — List ที่เก็บในลำดับคงที่ หรือ sort ยูนิตด้วย unique id ที่คงที่ก่อนเข้าลูป

บั๊ก 2 — floating-point math u.Attack * 1.5f เป็นการคูณ float และผลลัพธ์ floating-point ต่างกันได้ตาม CPU, compiler, และการตั้งค่า optimization ความต่างแค่บิตเดียว เมื่อ cast เป็น int ที่ขอบ (149.9999 เทียบกับ 150.0 ตัดเหลือ 149 เทียบกับ 150) ก็ให้สองเครื่องมีเลือดเป็น integer ต่างกันและ desync วิธีแก้: ทำ math ของ simulation ด้วย integer หรือ fixed-point — เช่น (u.Attack * 3) / 2 แบบ int — เพื่อให้ทุกเครื่องคำนวณได้ผลลัพธ์ตรงกันทุกบิต

Exercise 5 — คำนวณงบ Bandwidth ใช้วิธีจากหัวข้อ 15 เกม co-op เกมหนึ่งมี 40 entity ที่ต้อง sync ส่ง 20 ครั้งต่อวินาที และแต่ละ entity pack ลงเหลือ 8 ไบต์ (a) ไบต์ต่อวินาทีขาลงสำหรับ full packed snapshot ทุก tick เป็นเท่าไหร่? (b) ถ้า delta compression ทำให้มีแค่ 12 entity ที่เปลี่ยนใน tick โดยเฉลี่ย ไบต์ต่อวินาทีใหม่เป็นเท่าไหร่? (c) แปลงคำตอบข้อ (b) เป็น kbit/s
แสดงคำตอบ

(a) 8 ไบต์ x 40 entity x 20 tick/s = 6400 B/s

(b) ส่งเฉพาะ 12 entity ที่เปลี่ยน: 8 x 12 x 20 = 1920 B/s

(c) 1920 x 8 / 1000 = 15.36 kbit/s — เล็กมาก และเป็นเครื่องเตือนใจที่ดีว่า delta compression เพียงอย่างเดียว (ก่อนจะไปถึง interest management ด้วยซ้ำ) ก็เปลี่ยนสตรีม 6400 B/s ให้เหลือต่ำกว่า 2000 B/s ที่นี่แล้ว เพราะ entity ส่วนใหญ่อยู่นิ่งในเกือบทุก tick

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