目次
Open 目次
嬉しかったこと
- 想像していた以上に多くのユーザーに使ってもらえたこと(Google Universal Analytics 集計)
- SNS でも日々、steins.gg を使ってくれている様子が見えてとても嬉しかった。
- いくつかのプロチームで、分析をサポートする裏方として関われたこと
- それに伴い、リーグ選手の方々と直接やり取りする機会が増え、仲良くなれたこと
辛かったこと
- 運用費が想像以上に重かったこと
- サーバー費用が月 3,500 円
.ggドメイン登録費用が1年目で1.7 万円くらい、2年目で2.5万円くらい、3年目以降も費用が増加していく仕組みだったので、個人の無償サービス運用にはかなり効いた- サービス終了の最大の理由
- 広告を付けない方針にしていた
- 広告は、ユーザビリティやサイトの信頼度とトレードオフだと考えていて、
steins.ggでは「広告ゼロ」を貫きたかった
- 広告は、ユーザビリティやサイトの信頼度とトレードオフだと考えていて、
- 上記の結果、固定費を回収する手段がなく、継続が難しかった
- PUBG API の仕様変更・互換性ブレイクへの追従対応(エンドポイント追加/廃止、レスポンス構造の変更など)
- アクティブユーザー数の増減によるメンタルの揺れ
- パッチやゲームモードの流行りすたり、大会シーズンなどで数字が大きく動く
- このWebアプリは本当に役に立っているのか?と考え出すと、メンタル的に落ち込むことも多かった
ここからは技術的な裏話
技術スタック
フロントエンド: PUBG Web App(steins.gg/pubg)
- jQuery 3.3.1 / jQuery UI
- Bootstrap 3
- Fabric.js
- Canvas API を扱うために使用(マップ画像+円/線/アイコンなどのオーバーレイ描画)
- math.js
- 偏差値やベクトル計算などの数値処理に使用
バックエンド / サーバーサイド
- Nginx
- PUBG Web App を静的配信
- WordPress を FastCGI で PHP に接続
- Node.js API(内部 API サービス)をリバースプロキシ
- PHP(FastCGI)
- Nginx から FastCGI 接続
- WordPress(PUBG やその他のゲームに関する情報発信)の実行環境
- Node.js + Express(PUBG Web App 向け内部 API サービス)
- PUBG API からデータを取得し、アプリ用途に整形して返す API サーバー
その他
- ABLENET VPS
- ゴンベエドメイン
- おそらく
.ggドメインを購入できる唯一の日本向けサイト
- おそらく
こだわったポイント
サーバーをなるべく落とさない設計・運用
Steins.GG を開発する前に、前身である404systemというアプリを公開していました。
「とりあえず動くものを作ろう」のノリで運用していた結果、ゴールデンタイム(スクリム後くらいの時間帯)にアクセスが集中すると落ちることが頻繁にありました。
これは単純に一時的にアクセス過多による処理の過負荷やメモリ圧迫が原因でした。
Steins.GG ではそれを解消すべく、さまざまな工夫をしました。
入口をNginxに集約
公開ポートは Nginx の 80/443 のみにし、Node API と PHP(FastCGI)は localhost 側に閉じて、パブリックアクセスさせない設計にしました。
TLS 終端・アクセスログ・各種制限等を一元化できるので、アプリ層の過負荷やセキュリティリスクをなるべく低コストで抑えることができた。
プロセスはマルチ化
express-cluster で CPU コア数ぶん fork して、同時リクエストへの耐性を上げる方針にしました。
単一プロセスの Node よりも、マルチコア環境でスループットを伸ばしやすいのが狙いです。
また、worker 管理をライブラリに寄せることで実装コストを抑えつつ、落ちた worker を再起動できるので可用性が上がりました。
var cluster = require('express-cluster');
cluster(function(worker) {
server.listen(port, host, () => {
console.log('Server running on ' + host + ' : ' + port + ' : with pid ' + process.pid + ' : with wid ' + worker.id)
server.on('error', onError);
server.on('listening', onListening);
});
});
公式大会データは外部API依存にしたくない
大会データはアセット取得用 API が別で、かつ静的なデータなので、ユーザーのリクエストごとに都度取得するのではなく、サーバー側に静的データとして保持しました。 これにより、サーバー内での余計なリクエストやデータ処理を抑制して、より保守性を高められました。
外部依存を切って「高速・安定」を取りにいく判断。
Node.js の require() で読み込んだ JSON はモジュールキャッシュに載るため、同一プロセス内では繰り返しアクセスでも高速で返却処理出来た。
気を付けたこととして、PUBG API の利用規約に抵触しないよう、取得した Telemetry Data をそのまま再配布するのは避け、steins.gg の表示に必要な形へ整形した結果だけを保存する方針にしてました。
PUBG APIの巨大データは整形してフロントエンドに返す
Telemetry Data はサイズが大きく、サーバー側で毎回フルの生データを抱えると処理だいぶ重くなります。(特にゴールデンタイムなどアクセスが多い時)
そのため、サーバー側でまず match のメタ情報(map/time など)を正規化し、フロントには「表示に必要な最小限の情報 + Telemetry Data の URL)」の形で返すようにしました。
「matches endpoint の呼び出しはサーバー、Telemetry Data は用途次第でクライアントから直取り or サーバー経由 + 加工」という責務分離にしました。
キャッシュ機構を設ける
同じ入力で何度も API が呼ばれると、その都度
- 外部の PUBG API へ再アクセス
- 同じ JSON のパース/整形/集計を再実行
となり、レイテンシーや外部 API への負荷、同時アクセス耐性が悪化します。
そこで lru-cache を使ってキャッシュ層を入れました(公式のUsage Guideでもキャッシュは推奨されています)。
以下のようなラッパーを作って、
const LRU = require('lru-cache');
function createAsyncLruCache({ maxEntries, ttlMs, slidingTtl = true }) {
const cache = new LRU({
max: maxEntries,
maxAge: ttlMs,
updateAgeOnGet: slidingTtl,
});
return async function cached(key, fetcher) {
const hit = cache.get(key);
if (hit !== undefined) return hit; // 値 or Promise
const promise = (async () => fetcher())();
cache.set(key, promise, ttlMs);
try {
const value = await promise;
cache.set(key, value, ttlMs); // 成功したら値で置き換え(TTLリセット)
return value;
} catch (e) {
cache.del(key); // 失敗はキャッシュしない
throw e;
}
};
}
実装側ではこんな感じで使っていました
以下の例だと、マッチ ID とプラットフォームが同じなら、どのユーザーからリクエストが来ても同じレスポンスを即座に返せる。
const cacheMatch = createAsyncLruCache({
maxEntries: 5000,
ttlMs: 10 * 60 * 1000, // 10分
});
async function getMatchDataCached(matchId, pltfrm, getMatchData) {
const key = `match:${pltfrm}:${matchId}`;
return cacheMatch(key, () => getMatchData(matchId, pltfrm));
}
運用して気づいたのは、cluster(マルチプロセス)だとキャッシュは worker ごとなので、プロセスをまたいでキャッシュを効かせたいなら Redis などの共有キャッシュが必要だということです。
当時の主はまだ社会人 1 年目で、そこまで大きなサーバーを借りられる資金もなく、Redis プロセスを別で立てたり別サーバーで管理したりする余裕がなかったので断念しました。
数年後、いつかお金持ちになってもっとすごいサービスを作るときは使ってみたいね、Redis くん。
画像としての共有体験
分析結果や戦績をワンクリックで SNS に共有できることを、最初から強く意識して設計しました。
これは、利用者一人ひとりの「この戦績を見てほしい」という承認欲求を満たすと同時に、 利用者自身が Steins.GG の広告塔になってくれる仕掛けでもあります。
開発者である私が能動的にマーケティングをしなくても、 ユーザーの投稿を通じてサービスが自走的に広がり、アクティブユーザーが増えていくことを狙っていました。
日々の個人成績を SNS で共有したり、コミュニティ大会の総合ランキング画像を大会運営者がワンクリックで作れるようになりました。
数学的処理を math.js に任せる
用途は主に二つです。
一つ目は、steins.gg では偏差値という独自の採点を設けました。これはキル・アシスト数やダメージなど、さまざまな情報から独自に計算していました。
上記にもある通り、SNS で共有されることに重きを置いていたため、偏差値を計算して表示する仕組みは、かなりうまくいったと思います(SNS でも偏差値に関する話題が多かったです)。
// 偏差値(標準得点)の算出に使う指標キー
var DEVIATION_KEYS = [
"kills", // キル数
"DBNOs", // ノックアウト数
"assists", // アシスト数
"damageDealt", // ダメージ数
"timeSurvived", // 生存時間
"maxkills" // 最大キル数
];
function calcDeviationScores(players) {
if (!players || players.length === 0) return players || [];
var n = players.length;
// 指標ごとに「全プレイヤーの値一覧」を作る
var valuesByKey = DEVIATION_KEYS.map(function(key) {
return players.map(function(player) {
return player[key];
});
});
// 平均 μ(平均との差を出す基準)
var avgs = valuesByKey.map(function(values) {
return math.mean(values);
});
// 標準偏差 σ(z-score の分母)
// σ=0 は「全員同じ値」で標準化できないので 0 扱いにして後で 50 固定にする
var sds = valuesByKey.map(function(values) {
var sd = math.std(values, "uncorrected"); // 母標準偏差(÷n)。試合内分布をそのまま使う意図
return isFinite(sd) && sd !== 0 ? sd : 0;
});
// z-score: z = (x - μ) / σ(平均との差を“標準偏差単位”に直す)
// z を見慣れた 50中心のスケールに線形変換
for (var i = 0; i < n; i++) {
var player = players[i];
var deviations = DEVIATION_KEYS.map(function(key, j) {
var sd = sds[j];
if (!sd) return 50; // σ=0 なら相対差が定義できないので中立値に寄せる
return (10 * (player[key] - avgs[j]) / sd) + 50;
});
player.deviations = deviations;
// 合計偏差値(等重みで単純加算)
player.numdeviation = math.sum(deviations);
}
return players;
}
二つ目は、航路の描画に利用しました。
これは、飛行機の航路情報が match object には含まれていないため、Telemetry Data に含まれるプレイヤーの位置情報からベクトル計算して算出する必要があるからです。
どの航路で飛行機が飛んだかというのは、大会向けの分析目的にはかなり有用な情報なので、絶対に表示させたいと思って作りました。
function getRoute(mapJSON) {
var route = [];
// Telemetry Data から「飛行機に乗っているプレイヤー(TransportAircraft)の位置ログ」
// だけを抽出して点列にする
// route = [p0, p1, ...](p は {x,y,z})
for (var i = 0; i < mapJSON.length; i++) {
var e = mapJSON[i];
if (
e.vehicle &&
e.character &&
e.common &&
e.common.isGame >= 0.1 && // 0 は離陸前(初期島)のログなので除外する
e.elapsedTime < 150 && // 序盤だけ(離陸直後〜方向が安定する範囲を狙う)
e.vehicle.vehicleType === "TransportAircraft"
) {
route.push(e.character.location);
}
}
return route;
}
function drawRoute(route) {
if (!route || route.length < 2) return;
// 方向を推定したいだけなので、点列の「序盤の点」と「末尾の点」から方向を作る
// v = pe - ps(差分ベクトル)
var startIndex = route.length > 2 ? 1 : 0; // 先頭点がブレることがあるので2点目を使うことが多い
// 描画座標に合わせてスケール調整
var scale = 100;
var ps = { x: route[startIndex].x / scale, y: route[startIndex].y / scale };
var pe = { x: route[route.length - 1].x / scale, y: route[route.length - 1].y / scale };
var vx = pe.x - ps.x;
var vy = pe.y - ps.y;
// ||v||(ベクトルの長さ)
var len = math.sqrt(vx * vx + vy * vy);
if (!len || !isFinite(len)) return;
// u = v / ||v||(単位方向ベクトル=“向きだけ”を取り出す)
var ux = vx / len;
var uy = vy / len;
// p' = pe + extend * u(直線を少し延長して見やすくする)
var extend = 50;
var endX = pe.x + extend * ux;
var endY = pe.y + extend * uy;
renderRouteToCanvas(ps.x, ps.y, endX, endY);
}
次に新しく作るならどうする?
- ドメインは長期運用できるものを選ぶ。
- TLD によって更新料が大きく違うので、運用費の見積もりを先に出してから決める。
- インフラはクラウドのマネージドサービスで、運用負荷と固定費を下げる。
- フロントは静的配信に寄せる
- API はサーバーレス/コンテナでスケールさせる
- 描画は Fabric.js(Canvas 2D)ではなく WebGL ベースに寄せる。
- Canvas 2D は、基本 CPU 依存なので、オブジェクト数や再描画が増えると重くなりがち
- WebGL は大量描画やズーム/パン、レイヤー合成に強いので、
PixiJSなどの 2D WebGL ライブラリで実装コストを抑えつつ GPU レンダリングを使いたい
- 運用費の回収手段(寄付/広告など)を用意する。




