บทเรียนที่ผ่านมาทั้งหมดรันอยู่บนเครื่องเดียว มีผู้เล่นคนเดียว หน่วยความจำชุดเดียว และ CPU ตัวเดียวที่ตัดสินว่าอะไรคือความจริง แต่พอเราเพิ่มผู้เล่นคนที่สองผ่านอินเทอร์เน็ตเข้ามา สิ่งนั้นจะไม่จริงอีกต่อไป คอมพิวเตอร์สองเครื่องจะเริ่มไม่ตรงกันว่าตัวละครอยู่ตรงไหน ใครยิงโดนก่อน และใครกดปุ่มตอนไหน — และที่ไม่ตรงกันนี้เกิดจากฟิสิกส์ล้วนๆ ไม่ใช่โชคร้าย เพราะข้อมูลเดินทางเร็วกว่าความเร็วของการเชื่อมต่อระหว่างสองเครื่องไม่ได้ บทเรียนนี้พูดถึงสถาปัตยกรรม (architecture) ที่เกมใช้รับมือกับปัญหานี้ — ใครมีสิทธิ์ตัดสินว่าอะไรคือความจริง อะไรถูกส่งผ่านสายไป ส่งบ่อยแค่ไหน และเราย่อขนาดข้อมูลให้เล็กได้แค่ไหน เนื้อหาทั้งหมดในบทนี้เป็นทฤษฎีเรื่อง networking แบบไม่ผูกกับเอนจินไหนโดยเฉพาะ เขียนด้วย C# เพราะเป็นภาษาที่คุณจะใช้ implement จริงใน Unity และมีหัวข้อหนึ่งที่เชื่อมโยงกลับไปที่แพ็กเกจ multiplayer ของ Unity เอง
เกม singleplayer แค่ต้องสอดคล้องกับตัวเองภายในเครื่องเดียวก็พอ แต่เกม multiplayer ต้องทำให้คอมพิวเตอร์ตั้งแต่สองเครื่องขึ้นไปเห็นตรงกันว่าโลกของเกมเป็นยังไง ผ่านการเชื่อมต่อที่ทั้งช้า ทั้งไม่น่าเชื่อถือ และอาจมีข้อความจากคนที่ตั้งใจโกงปนมาด้วย สามปัญหานี้ — ช้า ไม่น่าเชื่อถือ ไม่ซื่อสัตย์ — คือเนื้อหาทั้งหมดของบทเรียนนี้
Latency (เรียกอีกอย่างว่า ping) คือเวลาที่ข้อความใช้เดินทางจากเครื่องหนึ่งไปอีกเครื่องหนึ่ง ต่อให้เดินทางด้วยความเร็วแสง ข้อความจากกรุงเทพฯ ไปเซิร์ฟเวอร์ที่สิงคโปร์ก็ยังใช้เวลาจริงที่วัดได้ — และตัวเลขจริงจะแย่กว่าค่าต่ำสุดตามความเร็วแสงเสมอ เพราะข้อความต้องผ่าน router สาย และคิวต่างๆ ระหว่างทาง แมตช์ออนไลน์ทั่วไปอาจมี latency ทางเดียวอยู่ที่ 30-150 มิลลิวินาที (ms) ฟังดูน้อย แต่เกมส่วนใหญ่รันที่ 60 เฟรมต่อวินาที หมายความว่าหนึ่งเฟรมกินเวลาประมาณ 16.7 ms — ดังนั้น latency 100 ms ก็ประมาณ 6 เฟรมของ "อดีต" กว่าข้อมูลจะมาถึง
ทุกการตัดสินใจออกแบบในบทเรียนนี้ — client-side prediction, snapshot, delta compression, tick rate — มีอยู่เพราะข้อเท็จจริงข้อเดียวนี้: ข้อมูลเกี่ยวกับโลกของเกมจะเก่าไปนิดหน่อยเสมอกว่าจะมาถึงคุณ และมีขีดจำกัดทางฟิสิกส์ที่คุณย่อความล่าช้านี้ให้เล็กลงไปกว่านั้นไม่ได้
ข้อความที่ส่งผ่านอินเทอร์เน็ตไม่ได้มาถึงเสมอไป Packet loss คือกรณีที่ส่งข้อความไปแล้วแต่ไม่มาถึงปลายทางเลย — อาจเพราะ router โหลดหนักเกินแล้วทิ้งแพ็กเก็ตนั้น สัญญาณ wifi กระตุก หรืออะไรก็ตาม ส่วน Jitter คือกรณีที่ข้อความมาถึงจริง แต่มาถึงด้วยช่วงเวลาที่ไม่สม่ำเสมอ — แพ็กเก็ต 1 อาจใช้เวลา 40 ms แพ็กเก็ต 2 อาจใช้เวลา 90 ms ทั้งที่ส่งห่างกันแค่ 16 ms โค้ด networking ของเกมต้องทำงานต่อไปได้ แม้ข้อความบางส่วนจะหายไปเฉยๆ และที่เหลือมาถึงด้วยจังหวะที่สับสน
ข้อความใดๆ ที่ 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) เท่านั้น
มีรูปแบบพื้นฐานอยู่สองแบบว่าใครคุยกับใครในเกม multiplayer
ใน peer-to-peer (P2P) เครื่องของผู้เล่นทุกคนเชื่อมต่อตรงกับเครื่องของผู้เล่นคนอื่นทุกคน ไม่มีคอมพิวเตอร์แยกต่างหากที่คอยประสานงานกลาง — เครื่องของผู้เล่นเองนั่นแหละคือเครือข่าย
P2P ช่วยประหยัดค่าใช้จ่ายในการรันเซิร์ฟเวอร์เฉพาะ และอาจมี latency ต่ำกว่าระหว่างผู้เล่นสองคนที่คุยกันโดยตรง เพราะข้อความไม่ต้องอ้อมผ่านเครื่องที่สาม มันปรากฏในเกมต่อสู้บางเกม เกมยิงรุ่นเก่าบางเกม และเกม co-op หรือเกมผลัดตาเล่นเล็กๆ อีกหลายเกม แต่ก็มีต้นทุนจริงๆ อยู่: เมื่อไม่มีใครเป็นเจ้าของความจริงเพียงคนเดียว peer แต่ละคนต้องหาทางตกลงกันเองว่าอะไรคือข้อเท็จจริง เช่น "ใครชนะการแข่งขัน" หรือ "ใครตีโดนก่อน" ซึ่งทำได้ยากอย่างเป็นธรรมเมื่อ peer โกหกได้ (ดูหัวข้อ 1.3) มันยังหมายความว่าคุณภาพการเชื่อมต่อของผู้เล่นแต่ละคนกระทบผู้เล่นคนอื่นทุกคนด้วย และถ้าเครื่องของผู้เล่นคนหนึ่งออกจากแมตช์ไป ก็อาจทำให้ทั้ง session พังได้ ถ้าไม่มี peer คนอื่นเข้ามารับหน้าที่เป็น "host" แทน
ใน client-server ผู้เล่นทุกคน ("client") เชื่อมต่อกับเครื่องกลางเพียงเครื่องเดียว (the "server") ผู้เล่นไม่คุยกันโดยตรงเลย — ทุกข้อความต้องผ่านเซิร์ฟเวอร์ก่อนเสมอ
Client-server ต้องมีเครื่องไว้รันเซิร์ฟเวอร์ — ซึ่งมีค่าใช้จ่าย ไม่ว่าจะเป็นเซิร์ฟเวอร์เฉพาะในดาต้าเซ็นเตอร์ หรือเครื่องของผู้เล่นคนหนึ่งที่ทำหน้าที่เป็น "listen server" (เซิร์ฟเวอร์ที่เล่นเกมไปด้วยในตัว) แลกกับสิ่งนี้ คุณจะได้ที่เดียวที่ความจริงอยู่ ที่เดียวที่ตรวจสอบว่าคำขอสมเหตุสมผลไหม และจำนวนการเชื่อมต่อที่ต้องจัดการน้อยลงมาก (ผู้เล่น N คนต้องการแค่ N การเชื่อมต่อ ไม่ใช่ประมาณ N ยกกำลังสอง) นี่คือเหตุผลที่เกม multiplayer แข่งขันเกือบทุกเกม — เกมยิง, MOBA, เกมแข่งรถ, battle royale — ใช้ client-server และเป็นเหตุผลที่บทเรียนที่เหลือนี้จะอิงกับสถาปัตยกรรมนี้เป็นหลัก
Client-server บอกแค่รูปร่างของการเชื่อมต่อ ส่วน authoritative server เป็นกฎเรื่องความไว้ใจที่ซ้อนทับอยู่บนรูปร่างนั้น: เซิร์ฟเวอร์เป็นเครื่องเดียวที่มีสิทธิ์ตัดสินว่าอะไรคือความจริงของโลกเกม client ไม่บอกข้อเท็จจริงกับเซิร์ฟเวอร์ ("ฉันอยู่ตำแหน่งนี้" "ฉันสร้าง damage เท่านี้") client บอกแค่ intent (ความตั้งใจ) กับเซิร์ฟเวอร์เท่านั้น — ผู้เล่นกำลังพยายามทำอะไร ("เดินไปทางขวา" "ฟันดาบ") — แล้วเซิร์ฟเวอร์เองจะเป็นคนคำนวณผลลัพธ์ โดยใช้กฎและฟิสิกส์ของเกมชุดของตัวเอง
สิ่งนี้แก้ปัญหาการโกงจากหัวข้อ 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 ไม่ใช่หลักฐานว่าไม่มีเซิร์ฟเวอร์คอยตัดสิน
เมื่อรู้แล้วว่าเซิร์ฟเวอร์ต้องส่งและรับข้อความเล็กๆ จำนวนมากในแต่ละวินาที คำถามถัดไปคือจะสร้างบนพื้นฐาน transport protocol (กฎระดับล่างสำหรับย้ายไบต์ระหว่างสองเครื่องผ่าน IP) แบบไหน มีตัวเลือกยอดนิยมอยู่สองแบบ
TCP (Transmission Control Protocol) การันตีสองอย่าง: ทุกข้อความที่คุณส่งจะมาถึง และมาถึงตามลำดับเป๊ะๆ ที่คุณส่งไป มันทำแบบนี้ได้โดยให้ฝั่งรับส่ง acknowledgment (การยืนยัน) กลับมาว่าได้รับอะไรบ้าง แล้วให้ฝั่งส่งส่งซ้ำทุกอย่างที่ไม่ถูกยืนยันภายในเวลาที่กำหนด หน้าเว็บ การดาวน์โหลดไฟล์ และข้อความแชท ใช้ TCP เพราะการทำไบต์หายหรือสลับลำดับถือเป็นบั๊กจริงๆ — คุณ render หน้าเว็บครึ่งไบต์ไม่ได้ หรือขาดข้อความแชทไปบรรทัดหนึ่งไม่ได้
การการันตีนี้มีต้นทุน: ถ้าข้อความหมายเลข 2 ในสตรีมหายไป TCP จะยังไม่ส่งข้อความ 3, 4, หรือ 5 ให้โปรแกรมของคุณ ทั้งที่มันมาถึงครบแล้วจริงๆ — เพราะการส่งให้แบบไม่เรียงลำดับจะทำลายสัญญา "เรียงลำดับ" โปรแกรมของคุณต้องนั่งรอให้ข้อความ 2 ถูกส่งซ้ำแล้วยืนยัน และทุกอย่างที่อยู่ข้างหลังก็ต่อคิวรออยู่ นี่คือสิ่งที่เรียกว่า head-of-line blocking: ข้อความเก่าที่หายไปหนึ่งข้อความ บล็อกทุกข้อความใหม่ที่อยู่ข้างหลังมันหมด แม้แต่ข้อความที่เกมของคุณไม่สนใจแล้วก็ตาม
UDP (User Datagram Protocol) ส่งข้อความไปแล้วไม่รอการยืนยัน ไม่ส่งซ้ำอะไรโดยอัตโนมัติ และไม่สนใจว่าข้อความจะมาถึงตามลำดับไหน ข้อความอาจมาถึง อาจมาถึงช้า อาจมาถึงแบบสลับลำดับ หรืออาจไม่มาถึงเลยก็ได้ — UDP ไม่สัญญาอะไรพวกนี้เลย แค่พยายามทำให้ดีที่สุดเท่านั้น แลกกับสิ่งนี้คือไม่มี head-of-line blocking: ข้อความที่หายหรือมาช้าจะไม่มีวันไปกันข้อความอื่นเลย เพราะ UDP ไม่เคยรออะไรทั้งนั้น
เกมแข่งขันแบบเรียลไทม์ส่วนใหญ่ส่งข้อมูลที่เปลี่ยนเร็ว — การเคลื่อนไหว การเล็ง การยิง — ผ่าน UDP แล้วค่อยเพิ่มความน่าเชื่อถือกลับเข้าไปเองด้วยมือ เฉพาะส่วนน้อยๆ ที่จำเป็นจริงๆ เท่านั้น ข้อสังเกตสำคัญคือข้อมูลในเกมไม่ได้ "เสื่อมสภาพ" เร็วเท่ากันหมด:
ดังนั้นแทนที่จะใช้แนวคิดแบบเดียวเหมาว่าใช้ได้กับทุกอย่างของ TCP ที่ว่า "ทุกอย่างต้องเชื่อถือได้และเรียงลำดับ" เกมจึงสร้างเลเยอร์ความน่าเชื่อถือบางๆ ซ้อนอยู่บน UDP ที่เลือกได้เป็นรายข้อความ: บางอย่างถูกส่งซ้ำจนกว่าจะถูกยืนยัน ส่วนใหญ่ไม่ถูกส่งซ้ำ ส่วนประกอบพื้นฐานคือ sequence number (เลข ID ที่เพิ่มขึ้นเรื่อยๆ ประทับอยู่บนทุกแพ็กเก็ตที่ส่งออกไป) และ ack (ย่อจาก acknowledgment — วิธีที่ฝั่งรับบอกฝั่งส่งว่า "นี่คือเลขแพ็กเก็ตสูงสุดที่ฉันได้รับ บวกกับว่าแพ็กเก็ตล่าสุดๆ ก่อนหน้านั้นฉันมีอันไหนบ้าง")
สังเกตสิ่งที่ ไม่ เกิดขึ้น: ไม่มีใครส่ง "แพ็กเก็ต 11" ทั้งแพ็กเก็ตซ้ำโดยมีตำแหน่งเก่าเดิมอยู่ในนั้น — กว่าใครจะสังเกตว่าแพ็กเก็ต 11 หายไป ตำแหน่งที่ใหม่กว่ามาก (จากแพ็กเก็ต 13, 14...) ก็กำลังเดินทางมาอยู่แล้ว สิ่งที่ถูกส่งซ้ำ ถ้ามี ก็คือเฉพาะข้อมูลข้างในแพ็กเก็ต 11 ที่ห้ามหายไปเด็ดขาดจริงๆ (เช่นเหตุการณ์ "ผู้เล่นเก็บดาบได้") ถูกแนบไปกับแพ็กเก็ตที่ส่งออกถัดไปทันที แทนที่จะเป็นข้อมูลตำแหน่งที่เก่าไปแล้ว
นี่คือเวอร์ชันย่อของแนวคิด 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) แทนการใช้ > ตรงๆ บทเรียนนี้ใช้เวอร์ชันตรงๆ ไว้ก่อนเพื่อให้แนวคิดอ่านง่าย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: มันเรียบง่ายและแก้ตัวเองได้ เพราะแต่ละอันเป็นภาพที่สมบูรณ์และเป็นอิสระ ไม่ต้องพึ่งว่าข้อความก่อนหน้าต้องมาถึงถูกต้องด้วย
ปัญหาคือขนาด เกมที่มี 60 entity ส่ง 20-30 ครั้งต่อวินาที ถ้าส่งคำอธิบายเต็มของทั้ง 60 ตัวทุกครั้ง รวมกันแล้วก็เป็นไบต์จำนวนมาก — ซึ่งส่วนใหญ่อธิบายสิ่งที่ไม่ได้เปลี่ยนแปลงเลยตั้งแต่ tick ก่อนหน้า หัวข้อ 8 จะแก้ปัญหานี้
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 จริงๆ ควรรันโค้ดแบบนี้ด้วยตัวเองแล้วเทียบกับสิ่งที่คาดหวังไว้เสมอ ไม่ใช่เชื่อผลลัพธ์ที่พิมพ์ออกมาแบบไม่คิดอะไร แม้แต่ในบทเรียนนี้เอง)
มีจุดละเอียดอ่อนที่เกมจริงๆ ต้องจัดการ: delta จะสมเหตุสมผลก็ต่อเมื่อทั้งสองฝั่งตกลงกันว่ามันเป็น delta จาก อะไร ถ้าเซิร์ฟเวอร์สร้างทุก delta เทียบกับ "tick ก่อนหน้า" เสมอ แต่แพ็กเก็ตล่าสุดของ client หายไป client คนนั้นก็ขาด baseline ที่ delta สมมติไว้ — ภาพโลกในหัวของมันจะค่อยๆ เพี้ยนไปเงียบๆ Netcode ระดับ production (เทคนิคที่ใช้ในเกมอย่าง Quake 3 และ Overwatch) จะสร้าง delta ของแต่ละ client เทียบกับ snapshot ล่าสุดที่ client คนนั้นโดยเฉพาะ ได้ยืนยันว่าได้รับแล้วแทน โดยใช้กลไก ack เดียวกันเป๊ะๆ จากหัวข้อ 6 ทำให้ client ที่พลาดแพ็กเก็ตไปบางอันยังได้ delta ที่ถูกต้อง (แม้จะใหญ่ขึ้นนิดหน่อย) เมื่อไล่ตามทัน
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 นี้
Interest management สำคัญด้วยเหตุผลสองข้อที่แยกกัน อย่างแรกที่เห็นได้ชัดคือ bandwidth — เกม open-world ขนาดใหญ่ที่มี entity เป็นร้อยๆ ตัว ไม่มีทางแบกรับการอธิบายทุกตัวให้ผู้เล่นทุกคนในทุก tick ได้เลย เหตุผลที่ไม่ค่อยเห็นชัดคือเรื่องความปลอดภัย: การส่งข้อมูลเกี่ยวกับสิ่งที่ client รับรู้ไม่ได้ คือวิธีที่ "wallhack" และการโกงลักษณะเดียวกันทำงานในเกมที่สร้างมาไม่ดี — ถ้าเซิร์ฟเวอร์ส่งตำแหน่งของศัตรูผ่านกำแพง "เผื่อไว้" client ที่ถูกแก้ไขก็แค่เลือกแสดงมันออกมาได้เลย Interest management เป็นวิธีแก้ปัญหานี้ด้วยเช่นกัน: ถ้าเซิร์ฟเวอร์ไม่เคยส่งข้อมูลที่ผู้เล่นไม่ควรมีเลย ก็ไม่มี client-side hack ไหนรั่วมันออกมาได้
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 เป๊ะๆ — ความคลาดเคลื่อนเล็กๆ นี้คือต้นทุนของการใช้บิตน้อยลง สำหรับตำแหน่งบนหน้าจอ ความคลาดเคลื่อนขนาดนี้มองไม่เห็นด้วยตาเปล่า คุณเลือกได้ว่าจะใช้กี่บิตตามความคลาดเคลื่อนที่เกมของคุณรับได้
ทุกอย่างที่ผ่านมาสมมติว่าเซิร์ฟเวอร์จำลองโลกเป็นสเต็ปแยกจากกันและห่างเท่าๆ กัน ไม่ใช่แบบต่อเนื่อง อัตราสเต็ปนี้คือ 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 วินาทีเป๊ะๆ เสมอ ไม่ว่าลูปจะรันแค่ครั้งเดียวหรือห้าครั้งติดกัน
Tick rate ยังเป็น trade-off ที่ผู้เล่นรู้สึกได้ตรงๆ ด้วย: tick rate ที่สูงกว่าหมายความว่าเซิร์ฟเวอร์เช็กและ update โลกบ่อยขึ้น ทำให้การโดนตีถูกบันทึกใกล้เคียงกับช่วงเวลาที่มันเกิดขึ้นจริงบนหน้าจอมากขึ้น และ input ถูกรับรู้เร็วขึ้น — แลกกับเวลา CPU ที่มากขึ้นและ snapshot ที่ส่งออกต่อวินาทีต่อผู้เล่นมากขึ้นบนเซิร์ฟเวอร์ นี่คือเหตุผลที่เกมยิงแข่งขันบางเกมโฆษณา tick rate ของเซิร์ฟเวอร์ตัวเอง (64-tick, 128-tick และอื่นๆ) เป็นจุดขาย: มันคือตัววัดตรงๆ ว่า authoritative server ยินดีจะ update ความเข้าใจของตัวเองเกี่ยวกับความจริงเร็วแค่ไหน
ทุกอย่างที่ผ่านมาเป็นทฤษฎีที่ไม่ผูกกับเอนจินไหนโดยเฉพาะ Unity ไม่ได้บังคับให้คุณต้องสร้าง sequence number, ack bitfield, delta compression และ fixed tick loop เองตั้งแต่ศูนย์ — มีแพ็กเกจ networking ยอดนิยมสองตัวที่ implement ส่วนใหญ่ให้คุณแล้ว งานของคุณส่วนใหญ่จึงกลายเป็นแค่ใช้คำศัพท์ของมันให้ถูกต้อง
NetworkBehaviour (คลาสลูกของ MonoBehaviour ที่รับรู้เรื่องเครือข่าย) NetworkVariable<T> (field ที่เซิร์ฟเวอร์เปลี่ยนแล้ว sync ไปยัง client ให้อัตโนมัติและมีประสิทธิภาพ — ข้างในใช้ delta-style update คล้ายกับหัวข้อ 8) และ RPC (Remote Procedure Call — การเรียกเมธอดที่รันจริงๆ บนอีกเครื่องหนึ่ง: ServerRpc รันบนเซิร์ฟเวอร์เมื่อ client เรียกมัน ส่วน ClientRpc รันบน client เมื่อเซิร์ฟเวอร์เรียกมัน)[SyncVar] (attribute ที่ทำเครื่องหมาย field ให้ sync อัตโนมัติจากเซิร์ฟเวอร์ไปยัง client เล่นบทบาทเดียวกับ NetworkVariable ของ NGO) และ attribute [Command] / [ClientRpc] (เล่นบทบาทเดียวกับ ServerRpc / ClientRpc ของ NGO)ทั้งสองแพ็กเกจสมมติว่ามี 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 โดยไม่มีโมเดลความคิดอยู่เบื้องหลังเลย
หัวข้อ 2 ให้รูปแบบสองแบบว่าใครเชื่อมต่อกับใคร และหัวข้อ 3 เพิ่มกฎเรื่องว่าใครถูกไว้ใจ ยังมีสถาปัตยกรรมแบบที่สามซึ่งจริงๆ แล้วเป็นคำตอบที่ต่างออกไปสำหรับคำถามที่ว่า "เราจะส่งอะไรกันแน่?" — deterministic lockstep แทนที่จะให้เครื่องเดียวจำลองแล้วส่งผลลัพธ์ออกไป ทุก เครื่องรันการจำลองชุดเดียวกันเป๊ะๆ และสิ่งเดียวที่ถูกส่งผ่านสายคือ input (คำสั่ง) ของผู้เล่นแต่ละคน — ไม่มีตำแหน่ง ไม่มีเลือด ไม่มี snapshot มีแค่ "ผู้เล่น 3 สั่งให้ยูนิตเหล่านี้ไปโจมตีจุด (120, 64)" ทุกเครื่องนำ input ชุดเดียวกันมาใช้ตามลำดับเดียวกันใน tick เดียวกัน แล้วต่างก็ไปถึง state ของโลกที่เหมือนกันด้วยตัวเอง
ผลตอบแทนคือ 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 สูงที่สุด: คนเดียวที่การเชื่อมต่อแย่ทำให้คำสั่งของทุกคนรู้สึกช้าไปครึ่งจังหวะ
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 ทำแบบนั้นไม่ได้ เพราะมันต้องใช้โลกทั้งใบในการจำลอง
หัวข้อ 7-9 กับหัวข้อ 13 จริงๆ แล้วเป็นสองปรัชญาที่ต่างกัน และคุ้มค่าที่จะเรียกชื่อมันวางเทียบกัน เพราะเกม multiplayer ทุกเกมเลือกอย่างใดอย่างหนึ่ง (หรือผสมกัน)
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 ที่แชร์กัน แทนที่จะเป็นแค่ตัวละครของคุณเอง
หัวข้อ 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) ที่วิ่งไปด้วยกันอีกด้วย
ทุกสถาปัตยกรรมในบทเรียนนี้ชนกำแพงเดียวกันจากหัวข้อ 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 จะสร้างขึ้นแบบเต็มรูปแบบ
(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 ก็ตาม
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 เป๊ะๆ
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 ด้วยเหตุผลเดียวกัน: เสียงพูดครึ่งวินาทีที่เก่าและถูกส่งซ้ำ มาถึงช้าก็ฟังไม่มีประโยชน์อะไร — เหมือนกับตำแหน่ง เพราะเสียงชิ้นที่สดกว่ากำลังเดินทางมาอยู่แล้ว ระบบเสียงจริงๆ จึงแค่ทิ้งส่วนที่มาไม่ทันไปเลย แทนที่จะพยายามไล่ตามให้ทัน
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 — เพื่อให้ทุกเครื่องคำนวณได้ผลลัพธ์ตรงกันทุกบิต
(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