บันทึก

อย่าปล่อยให้ Infrastructure กลายเป็นคอขวดของตัวเอง

💻 Development

ความหน่วงไม่ได้เกิดจากระยะทางอย่างเดียว แต่อาจเกิดจากสิ่งที่เราออกแบบให้ระบบต้องรอ

ผมทำเกม Serpent เล่นกันสองคนบน Browser แล้ว Host ไว้บน Cloudflare Workers ตอนแรกคิดว่าไม่น่ามีอะไรซับซ้อน เกมมีแค่งูสองตัว เคลื่อนที่บน Grid เดียวกัน ผู้เล่นคนหนึ่งบังคับ อีกคนก็เห็นการเคลื่อนไหวเดียวกัน

แต่พอลองเล่นออนไลน์จริง เกมกลับค้าง ทั้งที่เล่นคนเดียวบนเครื่องตัวเองได้ลื่นมาก ตัวเลข Ping บนหน้าจอขึ้นไปประมาณ 300 ms แล้วเกมก็หยุด อินพุตไม่เดิน หน้าจอขึ้นคำว่า WAIT ตอนแรกผมคิดว่า Cloudflare Workers อาจไม่เหมาะกับการทำ Real-time Game หรือเปล่า แต่พอลองไล่ดูจริง ๆ กลับพบว่าปัญหามันอยู่คนละเรื่องกัน

เกมไม่ได้ส่งตำแหน่งงู

ก่อนอื่นต้องอธิบายวิธีทำงานของเกมก่อน เกมนี้ไม่ได้ส่งตำแหน่งงูทั้งตัวไปมาระหว่างผู้เล่น แต่ใช้วิธีที่เรียกว่า Lockstep Simulation. Browser ของผู้เล่นทั้งสองคนจำลองเกมเดียวกันอยู่บนเครื่องตัวเอง ทั้งคู่มี Game State เหมือนกัน และคำนวณการเคลื่อนไหวที่ 60 FPS สิ่งที่ส่งผ่าน Network มีแค่ Input ของผู้เล่น เช่น กด LEFT, RIGHT, UP หรือ DOWN

เมื่อผู้เล่น A กดเลี้ยว อินพุตนั้นจะถูกส่งไปที่ห้องเกม แล้ว Forward ไปให้ผู้เล่น B เพื่อให้ทั้งสองฝั่งนำ Input เดียวกันไปคำนวณ การจำลองแบบนี้ต้องเป็น Deterministic Simulation ด้วย กล่าวคือ เมื่อมี Initial State และ Input ที่เรียงลำดับเหมือนกัน ทั้งสองเครื่องต้องคำนวณออกมาได้ผลลัพธ์เหมือนกัน

เกมจะเดินต่อได้ก็ต่อเมื่อทั้งสองฝั่งได้รับ Input ของ Frame นั้นครบแล้ว ถ้าฝั่งใดฝั่งหนึ่งยังส่งมาไม่ถึง ทั้งสองหน้าจอต้องหยุดรอ เพราะถ้าฝั่งหนึ่งเดินต่อไป แต่อีกฝั่งยังอยู่ Frame เดิม Game State ก็จะไม่ตรงกัน

133 ms คือเวลาที่เกมรอได้

ผมกำหนดให้เกมส่ง Input ล่วงหน้า 8 Frames หรือที่เรียกว่า INPUT_DELAY ที่ 60 FPS จะได้ 8 / 60 = 0.133 วินาที หรือประมาณ 133 ms นี่คือ Latency Budget ที่เกมมีให้กับเส้นทางของ Input ตั้งแต่เครื่องผู้ส่ง ผ่านห้องเกม ไปถึงเครื่องผู้รับ ถ้า Input มาถึงช้ากว่าหน้าต่างเวลานี้ เกมก็ไม่มี Input สำหรับ Frame ที่ต้องจำลอง และต้องหยุดรอ ส่วน WAIT ที่แสดงบนหน้าจอจะปรากฏเมื่อรอครบ 200 ms

ส่ง input ล่วงหน้า 8 เฟรม เกมจะเดินเมื่อทั้งสองฝั่งได้รับครบ

ทีนี้ก็เริ่มเห็นปัญหาแล้ว Ping ที่แสดงบนหน้าจอคือ RTT (Round-Trip Time) จาก Browser ไปยัง Durable Object ของห้องเกม แล้วกลับมา ไม่ใช่ Ping ไปยังเครื่องของผู้เล่นอีกคน สมมติว่าผู้เล่น A มี RTT 40 ms และผู้เล่น B มี RTT 60 ms ถ้าเส้นทางขาไปและขากลับมี Latency ใกล้เคียงกัน A จะไปถึงห้องเกมประมาณ 20 ms และจากห้องเกมไปถึง B อีกประมาณ 30 ms Input Delivery Delay จาก A ไป B จึงประมาณ 50 ms

เราต้องเอา RTT ของทั้งสองคนมาคิด ไม่ใช่ดูตัวเลขของคนใดคนหนึ่ง เพราะ Input ต้องเดินทางจากผู้ส่ง ผ่านห้องเกม ไปถึงผู้รับ ถ้าทั้งสองคนมี RTT 300 ms ก็หมายความว่า Input Delivery Delay อาจอยู่ประมาณ 150 + 150 = 300 ms ซึ่งเกินงบ 133 ms ไปมากแล้ว นี่เป็นเหตุผลว่าทำไมเกมถึงค้าง ทั้งที่ Internet ของผู้เล่นอาจไม่ได้มีปัญหาเลยก็ได้

ห้องเกมอยู่ไกลกว่าที่คิด

ผมพบว่าปัญหาแรกอยู่ที่การสร้าง Durable Object ก่อนหน้านี้ผมใช้รหัสห้อง 4 ตัว เช่น PLAY เป็นชื่อของ Durable Object โดยตรง

PLAY → Durable Object

เมื่อมี Request เข้ามาใช้งาน Object ครั้งแรก Cloudflare จะเลือก Data Center สำหรับ Object นั้น โดยปกติจะพยายามเลือกตำแหน่งใกล้กับ Request แรก และเมื่อสร้างแล้ว Object ก็จะไม่ย้าย Data Center ตามผู้เล่นที่เข้ามาทีหลัง แม้จะเรียก get() พร้อม locationHint ในภายหลัง (get() คือ DurableObjectNamespace.get() ที่ Worker เรียกเพื่อเอา stub แล้วส่ง request เข้า Durable Object ของห้องเกม) ก็ไม่ได้เปลี่ยนตำแหน่งของ Object ที่มีอยู่แล้ว ที่น่าสนใจคือ การมีชื่อห้องเป็น Durable Object ID โดยตรง ทำให้แม้แต่การส่ง Request ไปยังห้องที่ยังไม่มีข้อมูล ก็สามารถสร้าง Object เปล่าขึ้นมาได้ หากมีการเรียกใช้งาน Stub นั้นจริง

อีกอย่างที่ผมเพิ่งเข้าใจก็คือ Cloudflare มี Edge Data Center สำหรับรับ Request จากผู้เล่น กับตำแหน่งของ Durable Object ซึ่งไม่จำเป็นต้องอยู่ที่เดียวกัน ผมลองดู cf-ray จาก Response Header (cf-ray คือ header ที่ Cloudflare ใส่กลับมาในทุก response ค่าข้างหน้าเป็นรหัสของ request นั้น รหัสสามตัวท้ายเป็น colo ที่รับ request) พบว่าเป็น BKK ซึ่งหมายถึง Cloudflare Colo ที่ประมวลผล Request นั้นอยู่ในกรุงเทพฯ แต่ไม่ได้หมายความว่า Durable Object ของห้องเกมจะอยู่กรุงเทพฯ ด้วย ถ้า Object ถูกสร้างไปอยู่ฝั่งสหรัฐฯ RTT จากประเทศไทยอาจขึ้นไปประมาณ 250–320 ms ได้ ตัวเลขที่เห็นบนหน้าจอก็เริ่มสอดคล้องกับสิ่งที่เกิดขึ้น

ห้องเกมฝั่งสหรัฐฯ กับห้องที่วางไว้ใกล้เอเชียตะวันออกเฉียงใต้

อีกปัญหาหนึ่งคือทุกข้อความต้องเขียน Storage

นอกจากเรื่องตำแหน่งแล้ว ผมเจออีกปัญหาหนึ่งใน webSocketMessage ทุกครั้งที่มีข้อความเข้ามา ระบบจะเรียก persist() ก่อน Forward ข้อความไปยังผู้เล่นอีกคน (persist() คือฟังก์ชันใน Durable Object ของห้อง ที่เอาสถานะห้องตอนนั้นเขียนลง Storage ด้วย ctx.storage.put เพื่อให้ข้อมูลยังอยู่หลัง object หลับแล้วถูกปลุกใหม่) รวมถึงข้อความ Ping และ Input ด้วย (Ping คือข้อความที่ Browser ส่งมาวัด RTT ไปยังห้องเกม Input คือคำสั่งของผู้เล่นใน Frame นั้น เช่น LEFT, RIGHT, UP หรือ DOWN ที่ Lockstep ต้องใช้เดินเกม) ทั้งที่สองอย่างนี้ไม่ได้เปลี่ยนสถานะของห้องเกมเลย

ปัญหาคือ Durable Objects มีสิ่งที่ Cloudflare เรียกว่า Output Gate เมื่อมี Storage Write ที่ยังทำงานไม่เสร็จ ข้อความขาออกจะถูกพักไว้จนกว่า Write จะเสร็จ เพื่อให้มั่นใจว่า Client จะไม่เห็นข้อมูลที่ยังไม่ได้ Persist

ในเกมนี้ผู้เล่นทั้งสองคนส่ง Input ทุก Frame ที่ 60 FPS รวมกันประมาณ 120 Messages ต่อวินาที ข้อความทุกอันต้องผ่านขั้นตอนนี้ ทั้งที่ส่วนใหญ่เป็นเพียงการส่งต่อ Input ไปยังอีกคนหนึ่ง มันจึงมี Latency เพิ่มขึ้นมาอีกชั้น และอาจทำให้เกิดอาการกระตุกเป็นช่วง ๆ บนเส้นทางที่มี Network Latency สูงอยู่แล้ว ผมจึงพบว่าปัญหาไม่ได้มีแค่ห้องเกมอยู่ไกล แต่ยังมีงานที่ไม่จำเป็นอยู่ในเส้นทางที่เกมต้องใช้ทุก Frame ด้วย

แยกรหัสห้องออกจาก Durable Object

วิธีแก้ปัญหาแรกคือผมเลิกใช้รหัสห้องเป็น Durable Object ID โดยตรง เปลี่ยนให้รหัสห้องเป็นเพียง Key ใน RoomIndex ส่วน Durable Object ของแต่ละ Match จะสุ่ม ID ใหม่ด้วย newUniqueId()

Room Code: PLAY
      |
      v
   RoomIndex
      |
      v
Durable Object ID
      |
      v
   GameRoom

แบบนี้รหัส PLAY สามารถนำกลับมาใช้ได้อีก เพราะทุกครั้งที่สร้าง Match ใหม่จะได้ Durable Object ตัวใหม่ เมื่อสร้าง Object ใหม่ ผมก็ส่ง locationHint ให้ Cloudflare พยายามวางห้องไว้ใกล้ภูมิภาคที่ผู้เล่นเปิดห้อง โดยเลือก Hint จากข้อมูล request.cf.country และภูมิภาคของผู้สร้างห้อง

  • ประเทศใน ASEAN ใช้ apac-se
  • ญี่ปุ่น เกาหลี ไต้หวัน และฮ่องกง ใช้ apac-ne
  • ภูมิภาคอื่นเลือกตาม Continent
  • ถ้าไม่มีข้อมูล ใช้ apac-se เป็นค่าเริ่มต้น

แน่นอนว่า locationHint เป็นเพียงคำขอ ไม่ใช่คำสั่งที่รับประกันว่า Cloudflare จะสร้าง Object ใน Data Center ที่เราต้องการ แต่ก็ดีกว่าปล่อยให้ Request แรกที่ไม่เกี่ยวข้องกับผู้เล่นจริง ๆ เป็นตัวกำหนดตำแหน่งของห้องเกม ส่วน RoomIndex ผมวางไว้ใน Southeast Asia เพราะผู้เล่นส่วนใหญ่ของเกมอยู่แถวนี้

จัดการการจองรหัสห้องให้ปลอดภัย

RoomIndex มีหน้าที่รับผิดชอบการจองและค้นหารหัสห้อง ไม่ได้เข้าไปยุ่งกับการเล่นในแต่ละ Frame ผมแบ่งการทำงานออกเป็น 4 ขั้นตอน

claim เมื่อมีคนสร้างห้อง จะจองรหัสไว้ในสถานะ opening พร้อม Lease 15 วินาที ถ้ามีอีกคนพยายามสร้างรหัสเดียวกันในช่วงนี้ ก็จะได้รับคำตอบว่ารหัสถูกจองไปแล้ว

commit เมื่อสร้าง GameRoom สำเร็จ จึงเปลี่ยนสถานะเป็น live เพื่อให้เพื่อนเข้าห้องได้

release เมื่อห้องปิด จะลบรหัสออกจาก Index แต่จะลบได้ก็ต่อเมื่อ Durable Object ID ที่ขอลบยังเป็นเจ้าของรหัสนั้นอยู่ ป้องกันกรณีห้องเก่าปิดช้า แล้วเผลอลบรหัสที่ถูกนำไปใช้กับห้องใหม่แล้ว

rebind ถ้าห้องล่มก่อนที่จะคืนรหัส แถวใน Index อาจยังเป็น live อยู่ เมื่อมีคนสร้างรหัสเดิมอีกครั้ง ระบบจะตรวจสอบ GameRoom เดิมก่อน ถ้าห้องเดิมปิดไปแล้ว ก็จะเปลี่ยนรหัสนั้นให้ชี้ไปยัง Durable Object ตัวใหม่

อีกเรื่องที่ต้องระวังคือ Durable Objects เป็น Single-threaded ก็จริง แต่ไม่ได้หมายความว่า Request จะไม่มีโอกาสแทรกกันได้ เมื่อโค้ดทำ await โดยเฉพาะการรอ Network I/O การประมวลผลของ Request อื่นอาจเข้ามาแทรกได้ ผมจึงใช้ Storage Transaction ครอบการอ่านและเขียนแถวของรหัสห้องในแต่ละขั้นตอน เพื่อให้การเปลี่ยนสถานะเป็น Atomic

ส่วนการสร้าง GameRoom เป็น Network Call ไปยัง Durable Object อีกตัวหนึ่ง จึงไม่สามารถอยู่ใน Transaction เดียวกันได้ Lease 15 วินาทีจึงมีหน้าที่กันไม่ให้คนอื่นเข้ามาแทรกในช่วงที่กำลังสร้างห้อง

เอา Storage ออกจากทางเดินของเกม

ปัญหาที่สองแก้ด้วยการแยกข้อความที่เปลี่ยนสถานะห้อง กับข้อความที่แค่ต้องการส่งต่อ ผมปรับ relayMessage ให้คืนค่าว่าข้อความนั้นต้องเปลี่ยนสถานะของห้องหรือไม่

ข้อความเหล่านี้ไม่ต้องเขียน Storage

  • ping
  • input
  • hash สำหรับตรวจสอบว่า Simulation ยังตรงกันหรือไม่
  • desync สำหรับแจ้งว่า Simulation ไม่ตรงกัน

ส่วนข้อความที่ยังต้อง Persist คือ ready, match start และ disconnect เพราะเป็น Event ที่เกิดไม่บ่อย และเป็นข้อมูลที่ต้องนำกลับมาใช้ได้เมื่อ Durable Object ถูก Hibernated แล้วกลับมาทำงานใหม่

อีกอย่างที่เปลี่ยนคือ Ping จากเดิมที่ส่ง JSON พร้อม Timestamp ไปให้ Server แล้วให้ Server ส่งค่ากลับมา ผมเปลี่ยนเป็นข้อความคงที่

ping
pong

ฝั่ง Browser จะเก็บ Timestamp ของตัวเองไว้ แล้วจับเวลาเมื่อได้รับ pong กลับมา โดยมี Ping ค้างอยู่ได้ครั้งละหนึ่งข้อความ ข้อดีอีกอย่างคือ Durable Objects มี setWebSocketAutoResponse() ผมสามารถกำหนดให้ Runtime ตอบ pong กลับทันทีเมื่อได้รับ ping ที่ตรงกับข้อความที่กำหนดไว้ โดยไม่ต้องปลุก Durable Object ที่กำลัง Hibernated ขึ้นมาทำงาน ถ้าข้อความหลุดเข้ามาถึง Handler จริง ๆ ก็ให้ตอบ pong แล้วจบ ไม่ต้องโหลด Game State และไม่ต้องเขียน Storage

ก่อนหน้านี้ Ping ที่ส่งทุกวินาทีทำให้ห้องมี Event เข้ามาตลอด จึงไม่เข้าเงื่อนไข Hibernation ในช่วงที่รอเพื่อน แต่หลังเปลี่ยนมาใช้ Auto Response ได้แล้ว Runtime สามารถจัดการ Heartbeat นี้ได้โดยไม่ต้องปลุกโปรแกรม

เดิมทุกข้อความต้องรอเขียน Storage ตอนนี้ input ส่งต่อได้เลย ส่วน ping ให้ runtime ตอบเอง

ไม่เพิ่ม INPUT_DELAY เพื่อแก้ปัญหา

หลังจากเปลี่ยนทั้งสองส่วน ผมยังคง INPUT_DELAY ไว้ที่ 8 Frames เท่าเดิม เพราะถ้าปัญหาเกิดจากตำแหน่งของห้องเกมและการเขียน Storage ที่ไม่จำเป็น การเพิ่ม Delay จาก 8 เป็น 12 หรือ 16 Frames ก็เป็นเพียงการทำให้ผู้เล่นทุกคนต้องรอนานขึ้น แม้แต่คนที่มี Network ดีอยู่แล้วก็ต้องรอเหมือนกัน

เมื่อผู้เล่นอยู่ใกล้ GameRoom ทางเดียวก็เหลือประมาณ 15–40 ms ทำให้ Input Delivery Delay อยู่ในงบ 133 ms ได้สบายขึ้น สิ่งที่ผมต้องการคือแก้ Network Path ไม่ใช่ทำให้เกมยอมรับ Network ที่แย่ลง

Ping ที่ดี ไม่ได้แปลว่าเกมจะไม่ค้าง

หลังแก้แล้วผมพบว่าการดู Ping เพียงค่าเดียวไม่เพียงพอที่จะบอกว่าเกมจะเล่นได้ตลอดทั้ง Match หรือไม่ ตอนนี้สิ่งที่ต้องวัดจริง ๆ มี 4 ค่า

  1. RTT ของ Player A
  2. RTT ของ Player B
  3. Input Delivery Delay จาก A ไป B
  4. Input Delivery Delay จาก B ไป A

และต้องดูทั้งค่ากลางของ Distribution รวมถึง p95 และ p99 ด้วย เพราะ Ping ที่ดีในช่วงหนึ่งไม่ได้รับประกันว่า Input ของ Frame ถัดไปจะมาถึงทันเวลา

อีกข้อจำกัดที่ยังมีอยู่คือ ห้องเกมถูกสร้างตั้งแต่ตอนที่ผู้เล่นคนแรกกด Create ก่อนที่เราจะรู้ว่าผู้เล่นอีกคนอยู่ที่ไหน ดังนั้นคนเปิดห้องจะมีโอกาสได้ตำแหน่งที่ใกล้กว่า ส่วนเพื่อนที่อยู่อีกทวีปอาจมี Latency สูงกว่ามาก และเกมจะค้างตาม Input Delivery Delay ของเส้นทางที่ช้ากว่า ไม่ใช่ตาม Ping ของคนเปิดห้อง การเลือก Region ที่ดีที่สุดสำหรับผู้เล่นทั้งสองคนจึงยังเป็นโจทย์ที่ต้องแก้ต่อไป หากวันหนึ่งเกมมีผู้เล่นจากหลายทวีปมากขึ้น

1. ห้องเกมอยู่ไกลจากผู้เล่น

เดิมผมใช้ Room Code เป็น Durable Object ID โดยตรง ทำให้ห้องถูกสร้างและปักตำแหน่งตั้งแต่ครั้งแรกที่มีการเรียกใช้งาน และไม่สามารถย้ายตำแหน่งภายหลังได้

ผมแก้โดยแยก Room Code ออกจาก Durable Object ID ให้ RoomIndex ทำหน้าที่เก็บ Mapping ระหว่างรหัสห้องกับ GameRoom แล้วสร้าง Durable Object ใหม่ทุกครั้งที่เริ่ม Match พร้อมส่ง locationHint เพื่อให้ Cloudflare พยายามวางห้องไว้ใกล้ผู้เล่น

2. ทุก WebSocket Message ต้องรอ Storage Write

เดิมทุกข้อความที่เข้ามาต้อง Persist ก่อน Forward ไปยังผู้เล่นอีกคน รวมถึง Ping และ Input ที่ไม่ได้เปลี่ยนสถานะของห้อง ทำให้ข้อความที่ต้องการความเร็วต้องไปรอ Storage โดยไม่จำเป็น

ผมแก้โดยแยกข้อความที่ต้องบันทึกออกจากข้อความที่ต้องส่งต่อทันที ให้เฉพาะ Event ที่เปลี่ยนสถานะห้อง เช่น Ready, Match Start และ Disconnect เท่านั้นที่เขียน Storage ส่วน Ping และ Input ส่งต่อได้ทันที และใช้ setWebSocketAutoResponse() ให้ Runtime ตอบ Heartbeat ได้โดยไม่ต้องปลุก Durable Object

ผลคือ ผมยังใช้ Cloudflare Workers และ Durable Objects เหมือนเดิม แต่เปลี่ยนวิธีจัดการตำแหน่งของ GameRoom และเอา Storage Write ที่ไม่จำเป็นออกจากเส้นทางของเกม

สิ่งที่ได้เรียนรู้คือ บางครั้งปัญหาไม่ได้อยู่ที่ Technology ที่เราเลือก แต่อยู่ที่ว่าเราออกแบบให้มันทำงานอย่างไร

สิ่งที่ได้จากปัญหานี้

ผมไม่ได้ย้าย Game Server ไปไว้บน VPS เพื่อให้เลือก Data Center เอง แต่ยังใช้ Cloudflare ต่อเหมือนเดิม สิ่งที่เปลี่ยนคือวิธีออกแบบและใช้ Infrastructure ให้เหมาะกับลักษณะงานมากขึ้น จากปัญหาครั้งนี้ ผมได้บทเรียนอยู่หลายข้อ

1. Logical Identity ไม่จำเป็นต้องเป็น Physical Identity รหัสที่ผู้ใช้เลือกเองไม่ควรต้องกลายเป็น ID ถาวรของ Object ที่มีข้อจำกัดเรื่องตำแหน่งและ Lifecycle รหัสห้องควรเป็นแค่ Label ที่สามารถชี้ไปยัง Object ตัวใหม่ได้

2. Hot Path ไม่ควรต้องรอ Durable Write โดยไม่จำเป็น งานที่ต้องเกิดทุก Frame เช่น Input และการส่งต่อข้อความ ควรแยกจากงานที่ต้อง Persist เช่น การสร้างห้องหรือการบันทึกการเปลี่ยนสถานะ เพราะสองอย่างนี้มีความต้องการคนละแบบ ไม่จำเป็นต้องเดินผ่านเส้นทางเดียวกัน

3. Single-threaded ไม่ได้แปลว่าไม่มี Race Condition การมี JavaScript Thread เดียวช่วยลดปัญหาบางประเภท แต่เมื่อมี Asynchronous Operation และ await ก็ยังต้องคิดเรื่อง Concurrency และ Atomicity อยู่ดี

4. วัดสิ่งที่ระบบรอจริง ๆ เกมนี้ไม่ได้รอ RTT ของผู้เล่นคนใดคนหนึ่ง แต่รอ Input จากผู้เล่นทั้งสองให้มาถึงทัน Frame ที่กำหนด ดังนั้นตัวเลขที่เราเลือกมาวัดจึงต้องสัมพันธ์กับสิ่งที่ระบบกำลังรอ ไม่อย่างนั้นเราก็อาจกำลังแก้ปัญหาผิดจุด

5. Infrastructure ที่เป็นศูนย์กลางควรออกแบบให้แบ่งได้ ตอนนี้ RoomIndex มีเพียงตัวเดียวอยู่ใน Southeast Asia ยังไม่มีปัญหากับปริมาณการใช้งานปัจจุบัน แต่ถ้าวันหนึ่งมีผู้เล่นจากทั่วโลก การให้ทุกคนวิ่งกลับมาหา Index ตัวเดียวก็อาจกลายเป็น Bottleneck ได้ การแบ่ง Index ในอนาคตจึงต้องคิดตั้งแต่วันนี้ว่า คนที่รู้เพียงรหัสห้องจะสามารถหา Index ที่ถูกต้องได้อย่างไร

ผมคิดว่าปัญหาครั้งนี้ไม่ได้สอนแค่เรื่อง Network Latency แต่มันทำให้เห็นความสำคัญของการแยกหน้าที่ในระบบชัดขึ้นมาก Ping 300 ms เป็นแค่ตัวเลขที่บอกว่ามีบางอย่างผิดปกติ แต่การไล่หาว่าเกมกำลังรออะไร รอที่ไหน และรอสิ่งที่ไม่จำเป็นหรือเปล่า นั่นต่างหากที่ทำให้เจอสาเหตุจริง ๆ สุดท้ายผมยังใช้ Stack เดิม แต่ได้ระบบที่ออกแบบเหมาะกับงานมากขึ้น

สรุปสั้นๆ 2 ข้ออีกที

1. ห้องเกมอยู่ไกลจากผู้เล่น

เดิมผมใช้ Room Code เป็น Durable Object ID โดยตรง ทำให้ห้องถูกสร้างและปักตำแหน่งตั้งแต่ครั้งแรกที่มีการเรียกใช้งาน และไม่สามารถย้ายตำแหน่งภายหลังได้

ผมแก้โดยแยก Room Code ออกจาก Durable Object ID ให้ RoomIndex ทำหน้าที่เก็บ Mapping ระหว่างรหัสห้องกับ GameRoom แล้วสร้าง Durable Object ใหม่ทุกครั้งที่เริ่ม Match พร้อมส่ง locationHint เพื่อให้ Cloudflare พยายามวางห้องไว้ใกล้ผู้เล่น

2. ทุก WebSocket Message ต้องรอ Storage Write

เดิมทุกข้อความที่เข้ามาต้อง Persist ก่อน Forward ไปยังผู้เล่นอีกคน รวมถึง Ping และ Input ที่ไม่ได้เปลี่ยนสถานะของห้อง ทำให้ข้อความที่ต้องการความเร็วต้องไปรอ Storage โดยไม่จำเป็น

ผมแก้โดยแยกข้อความที่ต้องบันทึกออกจากข้อความที่ต้องส่งต่อทันที ให้เฉพาะ Event ที่เปลี่ยนสถานะห้อง เช่น Ready, Match Start และ Disconnect เท่านั้นที่เขียน Storage ส่วน Ping และ Input ส่งต่อได้ทันที และใช้ setWebSocketAutoResponse() ให้ Runtime ตอบ Heartbeat ได้โดยไม่ต้องปลุก Durable Object

ผลคือ ผมยังใช้ Cloudflare Workers และ Durable Objects เหมือนเดิม แต่เปลี่ยนวิธีจัดการตำแหน่งของ GameRoom และเอา Storage Write ที่ไม่จำเป็นออกจากเส้นทางของเกม