プログラミング
2004年のRuneScapeは、56kダイヤルアップ接続でマルチプレイヤーRPGをどう実現したか
2004 RuneScape fit a multiplayer RPG into 56k dial-up (jkm.dev)
要約
この記事は、2004年のRuneScapeが、当時の低速な56kダイヤルアップ接続でいかにしてリッチなマルチプレイヤーRPG体験を提供できたかを解説しています。当時の制約下で、Jagexはバイト単位でのデータ節約に極限までこだわり、パケットのオペコードのみを暗号化し、移動経路はデルタ座標と主要な角のみを送信するなど、ネットワーク通信の効率化を徹底しました。
全文翻訳
2004年のRuneScapeは、56kダイヤルアップ接続でマルチプレイヤーRPGをどう実現したか
2026年5月28日更新: 2026年5月29日 Ctrlキーに関する修正を追加。初期バージョンでは「強制実行」に使用され、後のアップデートで「移動モード反転」に変更されました。
開発 RuneScape ゲーム ネットワーキング
2004年、私は56kモデムでRuneScapeをやりすぎた。そのモデムは、母が電話を取ると同時に壊れた。3Dの世界、サーバーに最大数千人のプレイヤー、画面上には数十人が同時に表示される。ブラウザ上で、毎秒5キロバイト。それが機能したのだ。その仕組みを、一つのステップを追って見ていこう。
子供の頃は、亜麻を摘んだりゴブリンを倒したりするのに夢中で、これがどう機能するかなんて考える余裕はなかった。しかし、その答えは、バイトを無駄にしないという、持続的でほとんど強迫的なまでの努力にあった。それでは、立っている場所の1タイル北をクリックし、そのクリックからサーバーへ、そして他のプレイヤーの画面へ渡るすべてのバイトを追ってみよう。
中央広場、ヴァロック広場
方法論
この記事の詳細情報は、デコンパイルされた2004年のRuneScape 2クライアントから得られたものです。スニペットはデコンパイルからの rough translation で、可読性のために一部整形されていますが、ロジックはそのままです。
コアとなる原則はバージョン間で同一ではありませんが、そのほとんどはRuneScape Classic (2001) から現在のRuneScape 3、そしてもちろんOld School RuneScapeまで一貫しています。
制約
当時のJagexが直面していた制約を見てみましょう。
帯域幅。56kモデムは、下り56キロビット毎秒、上りはそれ以下で同期します。プロトコルのオーバーヘッドや回線ノイズを差し引くと、下り5 KB/s、上りはそれよりずっと少ないと見積もれます。2000年までにイギリスの家庭でもブロードバンドは利用可能でしたが、大半の世帯がブロードバンド接続を持つようになったのは2000年代後半であり、多くのプレイヤーはダイヤルアップ接続を利用していました。
2004年のブラウザ上のJavaアプレット。Javaアプレットはセキュリティサンドボックス内で実行されたため、生のネイティブソケットやUDPは使用できませんでした。すべてのバイトは単一のTCP接続を経由し、順序通りに、セグメントごとのオーバーヘッドを伴って転送されました。
600msのサーバーサイクル。RuneScapeのゲームサーバーは、約600ミリ秒の離散的なサイクル(またはティック)で進行します。各サイクルで、各プレイヤーについて、サーバーはそのプレイヤーが現在見ることができるすべてを計算し、次のサイクルまでに送信する必要があります。
暗号化レイヤー、簡潔に
ログインハンドシェイクが完了した後、ゲームパケットが送信される前に、小さな暗号化レイヤーが設定されます。これはバイトを節約するためではありません。これはスタックにおける唯一の暗号化です(ログインハンドシェイクの一部のRSA暗号化を除く)。そして、これは、後続のすべてのセクションが依存するまさにそのオペコードを保護するため、ここに存在します。
すべてのパケットは「オペコード」バイトで始まります。これは、パケットの種類を示す小さな整数です。そのオペコード(そしてオペコードのみ)は、ISAACというストリーム暗号で暗号化されます。2つのストリームが使用されます。1つはクライアントからサーバーへのトラフィック用、もう1つは逆方向用です。両方の側が両方のストリームを必要とします。クライアントは送信しようとしているものを暗号化し、受信したものを復号化し、サーバーも同様にミラーイメージ(接続されたプレイヤーごと)で行います。
両方のストリームは、共有された4つの整数のキーからシードされます。クライアントはそれらの整数のうち2つを自身で生成します。残りの2つはハンドシェイクの一部としてサーバーから提供されます。サーバーからクライアントへのストリームは、各ワードに50を加えた同じシードを使用します。これにより、2つの方向がキーストリームを共有しないようになります。
this.outboundCipher = new ISAAC(seed);
for (int index = 0; index < 4; index++) {
seed[index] += 50;
}
this.inboundCipher = new ISAAC(seed);
送信時の暗号化は1行です。
public void putOpcode(int opcode) {
this.putByte(opcode + this.outboundCipher.value());
}
受信時も同様です。
this.currentOpcode = (this.currentOpcode - this.inboundCipher.value()) & 0xFF;
したがって、パケット本体は暗号化されず、オペコードのみが暗号化されます。後述するように、オペコードは残りのパケットをどのように読み取るか、そしてパケットの終わりと始まりをどこで判断するかを示します。オペコードなしでは、本体は単なるバイトの壁であり、その1バイトを暗号化することは、サードパーティのパケットパーサーに対する最も安価な防御策でした。
歩行リクエストの送信
ここでは、タイルを1マス北にクリックしたときに何が起こり、それがどのようにサーバーに送信されるかを見ていきます。
ネットワーキングが発生する前に、クライアントはローカルの衝突マップを使用して幅優先探索を実行し、現在地からクリックした場所までのパスを構築します(この場合は簡単な探索です)。その後、サーバーが読み取るためのパケットを作成します。パスファインディングは標準的なものなので、ここでは詳しく説明しません。
パケットの最初の部分はオペコードで、その後にパケット本体の長さを格納する単一のバイトが続きます。ご覧のとおり、パケットに含まれるバイト数はパスのサイズに依存するため、この「長さ」バイトにより、サーバーはどこまで読み取るべきかを知ることができます。すべてのパケットにこの長さバイトがあるわけではなく、可変サイズの本体を含むパケットにのみ存在します。
開始位置は4バイト(2つのショート)、後続の各ウェイポイントデルタは2バイト、そしてCtrlキーが押されているかどうかを示す最後のバイトがあります。したがって、本体の長さは 4 + 2 * (pathLength - 1) + 1 です。
this.outboundStream.putOpcode(ClientToServerOpcodes.WALK_TILE);
this.outboundStream.putByte(4 + 2 * (pathLength - 1) + 1);
パケットには、パスの最初のウェイポイントの絶対位置(xとzはそれぞれ2バイトの「ショート」として送信)と、最初のウェイポイントに対するパスの各ウェイポイントのデルタが含まれます。軸ごとに1バイトの符号付きバイトで、これはバイトの範囲-128から127に余裕で収まります。なぜなら、1回のクリックで到達できる距離は限られているからです。
ここで、絶対座標(ステップあたり4バイト)ではなく、デルタのみを送信するという決定は、Jagexのネットワーキングにおける倹約の最初の例です。絶対座標で送信すると、単一の歩行パケットでは数バイトしか節約できませんが、ウェイポイントが1つ追加されるごとに4バイトではなく2バイトで済むため、ウェイポイントあたり50%の節約になります。
int firstX = pathX[0];
int firstZ = pathZ[0];
this.outboundStream.putShort(this.playerPositionX + firstX);
this.outboundStream.putShort(this.playerPositionZ + firstZ);
for (int i = 1; i < pathLength; i++) {
this.outboundStream.putByte(this.pathX[i] - firstX);
this.outboundStream.putByte(this.pathZ[i] - firstZ);
}
ここでのもう一つの倹約的な決定は、pathXとpathZがパスのすべてのタイルを含んでいるのではなく、角のみを含んでいることです。直線で10タイル歩いても、送信されるウェイポイントは1つだけです。目的地です。サーバーはすでにあなたの開始位置を知っているので、サーバー自身がラインを歩き、自身の衝突マップで検証します。
このパケットの最後の部分は、Ctrlキーが押されているかどうかを示す単一のバイトです。ゲームの初期バージョンでは、これは「強制実行モード」に使用されていましたが、後のバージョンでは現在の移動モードを反転させるために使用されます(「実行」がオフの場合はクリックした目的地まで歩き、オンの場合は実行します)。
this.outboundStream.putByte(this.keyStatus[Keys.CTRL] == 1 ? 1 : 0);
したがって、単一ステップの移動には、オペコードと長さマーカーを含めて7バイトしかかからないことがわかります。
WALK_TILE パケットバイトレイアウト
単一ステップの歩行パケットの7バイト: 1バイトのオペコード、1バイトの長さ(値5)、2バイトの宛先xショート、2バイトの宛先zショート、そして1バイトのCtrlキー押下状態。
オペコードと長さはヘッダーを構成し、残りの5バイトは本体を構成します。本体のサイズは長さバイトと等しくなります。
0123456
オペコード (暗号化済み)
長さ = 5
x (2バイトショート)
z (2バイトショート)
Ctrl/実行トグル
ヘッダー
本体 (5バイト)
WALK_TILE パケットバイトレイアウト
単一ステップの歩行パケットの7バイトを上から下に積み重ねたもの: バイト0 オペコード (暗号化済み)、バイト1 長さ (値5)、バイト2と3 宛先x 2バイトショート、バイト4と5 宛先z 2バイトショート、バイト6 Ctrlキー押下状態バイト。バイト0と1はヘッダー、バイト2から6は本体で、本体のサイズは長さバイトと等しくなります。
0123456
オペコード (暗号化済み)
長さ = 5
x (2バイトショート)
z (2バイトショート)
Ctrl/実行トグル
ヘッダー
本体
パスには単一ステップしか含まれていないため、ウェイポイントデルタを送信するループには入りません。したがって、長さマーカーと照らし合わせて5バイトのペイロードを確認できます。
4 + 2 * (pathLength - 1)