Browser में open world बनाना, भाग 15: baseline बदलें, फिर उसे sync करें
लेखक Oleg Sidorkin, Cinevva के CTO और सह-संस्थापक
यहां नए हैं? series guide का इस्तेमाल करें। यह बताती है कि spike क्या होता है और सभी भागों को जोड़ती है।
पहले चौदह भागों ने spikes 1 से 30 तक को कवर किया। वह सिलसिला एक ऐसे terrain system के साथ खत्म हुआ जिसे real time में sculpt किया जा सकता था, साथ ही एक character जो उस पर चल सकता था, glide कर सकता था, और गिर सकता था। यह भाग series को spike 31 से फिर शुरू करता है, और सबसे पहली चीज जो हमें तय करनी थी वह तकनीकी नहीं थी। वह यह थी कि उस सारे spike code का क्या किया जाए।
"replace करो, backport नहीं" वाला फैसला
हमारे पास 30 standalone HTML files थीं, हर एक एक अलग-थलग concept साबित करती हुई, और शून्य integration। production वाला world/client/ अभी भी पुराना stack था: WebGL, एक सरल heightmap, एक 75-line का character controller, नौ message types वाला एक MessagePack protocol। कोई editing नहीं, कोई WebGPU नहीं, कोई materials नहीं, कोई vegetation नहीं।
साफ-सीधा plan यह था कि spike के नतीजों को एक-एक करके उस production codebase में backport किया जाए। हमने उसे फेंक दिया। spike 30 के पास पहले से ही world/client/ से कहीं बेहतर terrain, physics, materials, vegetation, और camera था। पुराने WebGL code में backport करने का मतलब था पूरे रास्ते उससे लड़ना। तो हमने यह फैसला लिया कि world implementation को सबसे सफल spike से बदल दें और आगे बनाएं। spike 30 नया baseline बन गया, और world/client/ dead code बन गया।
इसने बाकी काम को नए सिरे से परिभाषित किया। "शानदार single-player tech demo" से "product" तक पहुंचने के लिए हमें multiplayer, persistence, infinite-world streaming, और object placement चाहिए था। multiplayer terrain sync पहले आया, क्योंकि यही वह है जो architecture को मजबूर करता है। यह जिस सवाल का जवाब देता है वह पूछना आसान है और गलत होने पर महंगा पड़ता है: जब Player A sculpt करता है, तो असल में wire पर क्या जाता है?
brush-param replay, pixel sync नहीं
Spike 31 को नए tab में खोलें ↗ · source देखें
network code की एक line लिखने से पहले, हमने ठीक-ठीक यह पता लगाया कि एक brush stroke क्या करता है। heightmap brush cursor के चारों ओर एक radius में एक CPU Float32Array में घूमता है, एक smoothstep falloff लगाता है, और या तो add, subtract, smooth, या flatten करता है। SDF brush वही चीज voxels के एक sphere पर 3D में करता है। दोनों paths शुद्ध CPU array math हैं। loop में कोई GPU compute नहीं, कोई randomness नहीं, कोई nondeterministic floating point नहीं। वही input array और वही params हर machine पर वही output देते हैं।
बस यही पूरी तरकीब है। हम edit किया गया terrain नहीं भेजते। हम brush parameters भेजते हैं, प्रति stroke tick 56 bytes, और हर client वही deterministic function फिर से चलाता है। sync protocol में चार message types हैं: peer discovery, brush message {op, wx, wy, wz, radius, strength, flattenTarget}, और 20Hz पर एक player-position message।
spike के लिए हमने server को पूरी तरह छोड़ दिया और BroadcastChannel इस्तेमाल किया, जो same-origin cross-tab messaging के लिए browser API है। दो tabs खोलें, वे बात करते हैं, शून्य infrastructure। यह sync वाले सवाल को latency, auth, और Durable Object wiring से अलग कर देता है। अगर param replay tabs के बीच converge करता है, तो यह WebSocket के पार भी converge करेगा।
एक ही जगह है जहां replay diverge हो सकता है, वह है order-dependent operations। Raise और lower commutative हैं, तो val + strength * falloff एक जैसा ही उतरता है चाहे किसने पहले लगाया हो। Smooth और flatten neighbor values पढ़ते हैं, तो ठीक उसी spot को ठीक उसी पल में smooth करते दो clients प्रति tick एक millimeter के अंशों जितना drift कर सकते हैं। व्यवहार में यह कभी नहीं होता, और production fix पहले से ही साफ है: edits को DO के जरिए route करें, उसे एक monotonic sequence number assign करने दें, client पर optimistically apply करें, और अगर authoritative sequence असहमत हो तो order ठीक कर दें। classic optimistic concurrency, और DO वैसे भी एक स्वाभाविक serialization point है।
वह peer capsule जो बार-बार गायब होती रही
Edits पहली ही कोशिश में sync हो गए। remote player का capsule नहीं हुआ। यह दूसरे tab पर अस्तित्व में आता-जाता टिमटिमाता रहा, और इसे ठोस बनाए रखने में तीन अलग bugs लगे।
capsule world origin पर spawn हुआ, जो terrain के नीचे दबा है, क्योंकि join message किसी भी position data से पहले आता है। Fix: इसे hidden शुरू करें और पहले position update पर इसे दिखाएं। position broadcast render loop के भीतर रहता था, और Chrome unfocused tabs पर requestAnimationFrame को throttle करता है, तो दूसरे tab का staleness check peer को हटा देता और अगला message उसे फिर बना देता। Fix: broadcast को setInterval पर ले जाएं, जो visible tabs के लिए throttle नहीं होता। और staleness timeout बहुत ज्यादा aggressive 5 seconds था, किसी भी GC pause से trip हो जाता था। Fix: इसे 30 seconds तक बढ़ाएं और normal closes के लिए साफ leave message पर भरोसा करें।
persistence और late-join, एक ही format
हमने persistence को एक नया spike शुरू करने के बजाय उसी spike में मोड़ दिया, क्योंकि serialization format एक जैसा है चाहे destination IndexedDB हो या कोई दूसरा tab। एक snapshot में पूरा heightmap (एक 129×129 Float32Array, करीब 66 KB) होता है, साथ केवल edit किए गए SDF chunks (हर एक
पहले persistence test ने एक बढ़िया ordering bug सामने ला दिया। घास init पर procedural heights का इस्तेमाल करके synchronously बिखेरी जाती है, लेकिन IndexedDB restore async है और बाद में heightmap को overwrite कर देता है, जिससे हर blade floating या धंसी रह जाती है। fix एक refreshAllGrass() pass है जो हर instance के नीचे की height को फिर से sample करता है और किसी भी ऐसी blade को छिपा देता है जो अब किसी खराब slope या altitude पर है। यही function load और late-join दोनों के लिए काम आता है।
slope की दास्तान
Sculpt किया गया terrain smooth procedural baseline से ज्यादा rough होता है, और इसने तीन physics bugs उजागर कर दिए जो पुराना terrain कभी नहीं कर पाता था। सीधे ऊपर की ओर चढ़ते हुए capsule बगल में slide करने लगता था। वजह एक velocity projection थी जो movement को ground के tangent बनाए रखने के लिए थी, लेकिन normal के केवल horizontal components के साथ लिखी गई थी। normal
drift एक दूसरे स्रोत से बना रहा। SDF collision probes body को gradient के साथ penetration depth जितना बाहर धकेलते हैं। किसी भी slope पर gradient के horizontal components होते हैं, तो 15° slope पर 0.1 m penetration प्रति step करीब 0.026 m बगल में धकेलता है, और 120Hz पर यह करीब 3 m/s का अदृश्य drift है। Fix: response को slope के हिसाब से बांटें। walkable ground पर (
तीसरे bug ने chunk boundaries पर capsule को freeze कर दिया, क्योंकि collision probes एक ही chunk के SDF को sample करते थे और जब कोई probe neighbor में पार जाता तो "deep in air" sentinel मिलता था। fix था sdfSampleWorld(wx, wy, wz) और sdfGradientWorld(...), जो किसी भी world position के लिए सही chunk ढूंढते हैं और जहां कोई SDF मौजूद नहीं वहां एक heightmap distance estimate पर fall back करते हैं। SDF-से-heightmap collision transition अब continuous है।
पानी दुनिया को पूरा करता है
Spike 32 को नए tab में खोलें ↗ · source देखें
यहां तक हर spike "पानी के ऊपर जमीन" था। Spike 32 ने एक ocean जोड़ा, और उसके साथ एक नया movement verb। हमने एक ऐसे terrain में water level 22 पर सेट किया जो करीब 8 से 58 तक फैला है, जो निचली घाटियों को भर देता है, shoreline पर beaches छोड़ देता है, और खेलने के लिए काफी सूखी जमीन रखता है।
surface एक MeshStandardNodeMaterial है जो TSL में बना है, terrain जैसा ही node approach। अलग-अलग frequencies पर तीन overlapping sine waves vertices को displace करती हैं, और surface normal mesh normals के बजाय उन waves के analytic cosine derivatives से आता है। Color एक depth estimate
Swimming एक buoyancy spring है। player swim mode में आता है जब feet water level से नीचे गिरते हैं और body center surface से एक capsule half-height के भीतर होता है। एक spring body को surface के ठीक नीचे एक target की ओर खींचता है, और 4 की water damping के खिलाफ 12 के buoyancy constant के साथ player अपना सिर बाहर रखकर stably bob करता है, कोई oscillation नहीं। Swim speed floaty acceleration और drag के साथ walking से धीमी है, surface के पास jump करने पर आप normal jump velocity के 60% पर बाहर launch होते हैं, और entry downward velocity को -5 m/s पर cap करता है ताकि आप plunge न करें। Terrain collision अभी भी underwater चलता है, तो आप lake bed पर चल सकते हैं जहां वह swim target से ऊपर उठता है। swimming flag position broadcast के साथ चलता है तो peers आपको swim करते देखते हैं, और एक HTML gradient overlay view को tint करता है जब camera surface से नीचे डुबकी लगाता है।
इस अध्याय में संदर्भित तकनीक
Deterministic brush-param replay. Edit किया गया terrain stream करने के बजाय, हर client केवल brush parameters भेजता है और वही CPU function फिर से चलाता है। यह इसलिए काम करता है क्योंकि heightmap और SDF दोनों brushes बिना किसी randomness या GPU nondeterminism के शुद्ध Float32Array math हैं, तो एक जैसे inputs हर जगह bit-identical outputs पैदा करते हैं। Payload प्रति stroke tick 56 bytes है। Commutative operations (raise, lower) order की परवाह किए बिना converge होते हैं, जबकि neighbor पढ़ने वाले operations (smooth, flatten) को convergence की गारंटी के लिए एक serialization point चाहिए, जो production Durable Object monotonic sequence numbers के जरिए देता है।
WebSocket के विकल्प के रूप में BroadcastChannel. शून्य server के साथ same-origin cross-tab messaging के लिए एक browser API। यहां इसे sync protocol को network latency और authentication से अलग करके test करने के लिए इस्तेमाल किया गया। serialization format (raw Float32Array heightmap साथ edit किए गए SDF chunks साथ MC-locked chunk IDs) वही bytes हैं जो IndexedDB persistence और late-join state transfer के लिए इस्तेमाल होते हैं, तो एक format तीन काम संभालता है।
Slope-split SDF collision response. जब कोई capsule probe volumetric terrain में penetrate करता है, तो भोला-भाला fix body को SDF gradient के साथ penetration depth जितना बाहर धकेलता है। slopes पर उस gradient के horizontal components होते हैं, जो lateral drift inject करते हैं। response को इस तरह बांटना कि walkable surfaces (
Analytic wave normals के साथ TSL water. ocean एक node material है जिसके vertices तीन जोड़ी गई sine waves से displace होते हैं। displacement के बाद mesh normals फिर से compute करने के बजाय, surface normal wave functions के cosine derivatives से analytically निकाला जाता है, जो सस्ता है और एक coarse grid पर finite-difference normals के artifacts से बचता है। Depth-driven color, shoreline foam, और depth-driven transparency सब एक ही depth estimate पर key करते हैं।
Buoyancy-spring swimming. Swim physics body को surface के ठीक नीचे एक target की ओर खींचे गए एक damped spring के रूप में model करता है। buoyancy constant 12 और damping 4 के साथ, player बिना oscillate किए surface पर settle हो जाता है। अलग movement constants (धीमी speed, floaty acceleration, भारी drag) swimming को walking से एक अलग feel देते हैं, और मौजूदा capsule-vs-terrain collision underwater काम करता रहता है।
29 में से भाग 15। पिछला: भाग 14 - दुनिया जीवंत हो उठती है अगला: भाग 16 - एक ऐसी दुनिया के लिए structure जो बढ़ती रहती है Series guide: /hi/blog/2026-02-25-open-world-browser-series-guide