PRODUCT NOTE / NETWORK DESIGN

NUKERO!

迷路をいちばん速く抜けるゲーム。オンラインでは 2〜4 人が別々の端末で同じ迷路を走る。
この文書は遊び方ではなく、端末どうしをどうつなぎ、どちらの向きに何を流すと決めたかを記録した開発ノートです。

ステータスβ版(Web と Android の APK を配布中)。ストア版は未リリースで、リリース日は未定
現行バージョンv0.6.0(2026-09-23 の Web ビルドと APK)。ストア版で v1.0.0 にする
遊ぶnzw.jp/static/app/nukero/web/(インストール不要。部屋コードを渡せば別の端末から入れる)
初版2026-09-21(最初のコミット)
前身爆弾ゲーム(10 数年前) ・ Algo Derby(2025-12) ・ BOM MAN ARENA(2026-03) ・ グループチャット(2026-06)。下の「前身」を参照
提供形態Web と Android の APK 直配布(Flutter)。NUKERO.apk。iOS はビルド(IPA)まで用意したが、ストアには出していない
通信WebSocket(wss://nzw.jp/v1/ws/maze/{部屋コード})・メッセージは JSON
サーバーnzw.jp の API(FastAPI)に同居する中継 maze_ws.py(203 行)。部屋はメモリだけ。端末側の通信は 1,341 行
最終更新2026-10-10
技術Flutter ・ 純 Dart の core/(決定的な進行)・ web_socket_channel ・ speech_to_text
関連リンクSynapse Link の開発ノート(固定ステップで踏んだ 2 倍速) ・ プロダクトの系譜 ・ 開発ノート一覧
TL;DR

ひとことで

オンラインでは、進行を部屋を作った人(ホスト)の端末が持ち、サーバーは部屋と席の管理と受け渡しだけを行う。

  • 迷路は送らず、シードから各端末で作る。走っている間に変わるもの(壁の傷・動くゴール)だけ写しに載せる。
  • ホストは 1 秒に 20 回、動くものの「写し」(1 回 204〜782 バイト)を配る。
  • ゲストが送るのは入力だけ。押しっぱなし・1 回きり・行き先の 3 つに欄を分けている(34〜63 バイト)。
  • 声は端末の中で文字にして、サーバーには文字だけが流れる。

2026-09-23、Web 版をβとして公開した。通信の作りは手元の 2 台では確かめきれないので、インストールの要らない形を先に出して人の回線で試す。

20 回/秒
ホストが写しを配る頻度(3 tick ごと)
782 B
写し 1 回の最大(協力 4 人・ステージ 8)
11 ms
中継 1 回(端末→サーバー→端末)の中央値

計測 2026-09-23(β版のコード)。大きさは core/ をそのまま回して JSON にしたもの、中継は開発機から本番の wss://nzw.jp へホストとゲストをつないで 40 回ずつ。内訳は下の「実測」。

Problem

背景・オンラインで解きたかったこと

遊び方人数通信進行を持つ端末
ひとりで1 人(銃ありのタイムアタック)なし自分の端末
みんなで(1 台)2〜4 人・CPU も入れられるなしその端末
オンライン対戦2 人。撃たれた相手は 2 秒しびれるWebSocketホストの端末
オンライン協力2〜4 人。味方には当たらないWebSocketホストの端末
Lineage

前身:どこから持ってきたか

このゲームは 2026-09-21 に始まって 3 日で今の形になっている。ゼロから考えたからではなく、四つの前身から部品を持ってきたからで、新しく書いたのはそれらを決定的につなぎ直す部分だった。前の 3 本はどれも素の JavaScript で、344 行・2,882 行・2,648 行ある。

ただしどれの後継でもない。BOM MAN ARENA は「爆弾を置いて敵を全滅させる」ゲームで、こちらは「迷路をいちばん速く抜ける」ゲーム。爆弾も炎もパワーアップも 1 つも入っていない。持ってきたのは遊びではなく、その下の仕組みのほうだった。

前身いつ持ってきたもの置いてきたもの
爆弾ゲーム 10 数年前
2023-09 公開
マス目の偶数・奇数で柱を立てる格子の壁。壊せる壁があるという考え方 ランダムに歩くだけの敵、0.7 秒ごとの setInterval、座標が一致したら当たりという判定
Algo Derby 2025-12 迷路の掘り方(深さ優先の穴掘り法。掘った先だけでなく間のマスも抜く書き方まで同じ)、ゴールからの距離場を幅優先で作って順位に使うこと、そして迷路をレースにするという遊びそのもの 右手の法則・集合知・量子トンネルといったアルゴリズムの見本市と、賭け。何よりシードを持たない乱数
BOM MAN ARENA 2026-03 敵の巡回の決め方(直進を優先し、分かれ道でだけ選び直し、行き止まりで反転)、見つける距離 8、追跡者の「巡回・追跡・見失い」の三つの状態、土管のワープ、入った部屋だけ見せる霧、効果音の作り方 爆弾・炎・キック・リモコン・パワーアップの全部。勝ち方も「敵を全滅させる」から「速く抜ける」へ変えた
グループ
チャット
2026-06 部屋ごとに接続を持って自分以外へ配る中継の骨格、4000 番台の close コードで断る理由を返す作法(「部屋が無い」の 4004 は同じ番号を使っている)、先に受けてから検査して閉じる順番 ログイン(JWT と許可リスト)、データベース、REST。部屋の作成も照会も、WebSocket の接続そのもので行っている

いちばん大きく変えたのは「決定的にした」こと。Algo Derby は迷路も走りっぷりも実行のたびの乱数で決まるので、同じレースは二度と再現できない。NUKERO! は逆に、同じ設定・シード・入力なら必ず同じ結果になるよう core/ を縛った。迷路を送らずシードだけを配るオンライン(下の決定)は、この書き換えの上にしか建てられない。

BOM MAN ARENA から持ってきた部分も、そのままでは使えなかった。壊れ方が分かっていた 3 つは、コードの中に日付つきで直した記録が残っている。

この 4 本がどう分かれてきたかは プロダクトの系譜に 1 枚の図で置いてある。爆弾ゲームから BOM MAN ARENA までが実線(同じ血)で、NUKERO! へ入る 3 本は破線(部品の受け渡し)になっている。

Architecture

構成と、流れる向き

ホストの端末が進行を持ち、nzw.jp の中継を通してゲストへ写しを配る。ゲストは押したボタンだけを送り返す。メッセージは誰からでも自分以外の全員へ届く。 迷路そのものは送らない。start に入れたシードから、各端末が同じ迷路を作る ホスト 部屋を作った人の端末 進行を持つ(60 tick/秒) 敵・弾・当たり判定・CPU 3 tick ごとに写しを作る nzw.jp の中継 /v1/ws/maze/{code} 部屋と席を管理する 上限を超えたら捨てる 進行は持たない ゲスト 1〜3 人 部屋に入った人の端末 届いた写しを映す 合間は見た目だけ進める 押したボタンを送る snap 20 回/秒 start・back そのまま全員へ in 変わったときだけ in + 席番号 s chat:誰からでも自分以外の全員へ 声を文字にしたものか定型文のコード。サーバーが席番号を足し、40 字で切り、1 秒に 1 通まで

狭い画面では図を横にスクロールできる。

メッセージはすべて JSON で、種類を t に入れる。サーバーが見るのは t と送り手の席だけで、行き先を決めたら中身には触らない。

種類(t)向き中身頻度
startホスト → ゲスト全員ステージ番号・シード・HP・人数・空き席レースごとに 1 回
snapホスト → ゲスト全員d の下に写し(人・敵・矢・弾・開いた部屋・その間の出来事)3 tick ごと(20 回/秒)と、決着の瞬間
backホスト → ゲスト全員全員をロビーへ戻すレースのあと
inゲスト → ホスト押しっぱなしの向きと撃つ、1 回きりの合図、タップの行き先。サーバーが席番号 s を付ける変わったときと 1 回きりの合図。押しっぱなしの念押しに 1 秒ごと
chat誰でも → 自分以外の全員文字 m か定型文のコード q。サーバーが s を付ける1 人 1 秒に 1 通まで
joined / lobby / left / closedサーバー → 本人か全員自分の席・埋まっている席・抜けた席・ホストが抜けて閉じた出入りのたび

in の中は、押しっぱなし・1 回きり・行き先の 3 つに分けてある。ホストはこの 8 つの欄を読むだけで、相手が 4 つの操作のどれで遊んでいるのかを知らない。

欄種類中身いつ送るか
h押しっぱなし押している向き(無ければ null)変わったとき・1 秒ごとの念押し
f押しっぱなし撃つボタンを押しているか同上
tp1 回きり十字を 1 回押した向き押した 1 回だけ
sw1 回きりスワイプした向きスワイプした 1 回だけ
st1 回きりタップで撃ったタップした 1 回だけ
ta行き先タップしたマス [x, y]タップした 1 回だけ
tr行き先全体マップでタップした部屋の番号(完全自動)タップした 1 回だけ
tw設定タップの歩き方(straight / full)行き先を送るときだけ。既定(そのまま)は省く

接続を断るときは WebSocket の close コードで理由を返す。端末は理由ごとに文言を変える。

コード理由端末の扱い
4009作ろうとした部屋コードがもう使われているホストが別のコードで作り直す(5 回まで)。人には見せない
4004その部屋コードの部屋が無い「見つからない」
4010もう始まっている途中からは入れない
4011満員(対戦 2 席・協力 4 席)「満員」
4503部屋数が上限(200)区別していない。通信の失敗と同じ表示
4400部屋コードか遊び方の形が不正アプリからは起きない
Decision Log

主要な意思決定ログ

日付はその判断が入ったコミットの日。新しいものが上。

v0.6.0 やられてよいのは 3 回まで。やられたら 3 秒数えてから再開する
DECISION

一発アウトはそのままに、やられてよい回数を 3 回にした。3 回目でゲームオーバーになり、その面はやり直し。やられるたびに3 秒数えてから動き出す(その間は敵にも罠にも当たらない)。

WHY / 仮説

これまでは何回やられても続けられたので、失敗に重みが無かった。部屋の入口に戻されるだけなら、運が悪ければ押し続けるだけで進めてしまう。

すぐ動き出す作りも問題だった。やられた瞬間は何が起きたのか見えないまま再開するので、同じ敵にもう一度突っ込むことがあった。3 秒あれば、敵の位置と自分の戻り先を見てから動ける。

RESULT

上の帯に残り回数(ハート 3 つ)を出し、やられたときは「アウト! / あと n 回 / 3・2・1」を盤面の真ん中に出す。

CPU の走りを測るテストは、残機を 999 にして回すようにした。既定のままだと残機切れで止まり、道さがしの質(クリア率・強さの順)が測れなくなる——実際、最初は Lv4 のクリア率が 95% を割ってテストが落ちた。

v0.6.0 世界地図はビルの断面にして、階を 1 つずつ開く
DECISION

10 階ぶんの面の札を全部並べるのをやめ、中を開くのは 1 階だけにした。ほかの階は薄い帯にして、進み具合の升目(10 個)だけ出す。左端に階段の線を通し、上に地面の線を引いてビルの断面に見せている。あわせて、操作の選択を世界地図より前に移した。

WHY / 仮説

「階層が縦長すぎて、操作ボタンが下にあるのが気付けない」と言われた。測ったら 10 階ぶんで 5,000px を超えていて、その下に置いたものは存在しないのと同じだった。

階をたたんでも困らない。いま遊ぶのは 1 つの階で、ほかの階は「どこまで進んだか」が分かれば足りる。升目ならひと目で分かる。

RESULT

地図の高さは 1/4 ほどになり、操作の選択が最初の画面に入るようになった。ゲーム中のポーズ画面でも変えられる(v0.5.2)ので、入口は 2 つになった。

作るときに 2 回落とし穴を踏んだ: 階段の線を `Row` の stretch で作ったら高さが無限になって落ち(地図は縦に伸びるリストの中にある)、升目 10 個は幅の狭い画面ではみ出した。線は左の border に、升目は縮む箱に入れた。

v0.6.0 壊せない壁に当たった弾は、弾かれたことを見せる
DECISION

壊せない壁に弾が当たったとき、当たった面で光らせ、火花を来た方向へ跳ね返す(0.26 秒)。壁の中心ではなく手前の面で光らせる。

WHY / 仮説

これまでは弾が黙って消えていた。壊せる壁はひびが入るので手ごたえがあるが、壊せない壁は「当たったのかどうか」も分からなかった。弾かれる絵があれば、撃っても無駄だと 1 発で伝わる。

壁の中心で光らせると、壁が壊れたように見えてしまう。だから手前の面で光らせる。

RESULT

進行には関わらない(出来事を 1 つ増やして画面側で描くだけ)。偶然を待たずに確かめられるよう、スプライト一覧に 3 コマ描き出すようにした。

v0.5.2 指を離したら止まる。自動で走る操作をやめ、押さえている間だけ歩く
DECISION

スワイプ操作を「なぞって押さえている間だけ、その向きへ歩く」に変えた。指を離したら止まる。これまでは、スワイプした向きへ分かれ道まで自動で走り続けていた。

あわせて、十字ボタンと撃つボタンの当たり判定を絵の外まで広げ(各 22px)、ボタン自体も少し大きくした。

WHY / 仮説

「押してないのに勝手に移動することがある」と言われた。実際そのとおりで、指を離したあとも分かれ道まで走る作りだった。速く動けるが、止めたいところで止まらない。迷路は一発アウトなので、止まれないことの損のほうが大きい。

十字ボタンは「押せる範囲が狭い」。絵の大きさ=触れる大きさにしていたが、指はふちを外す。絵より外側にも効く余白を付けた(見た目は変えずに触れる範囲だけ広げる)。

RESULT

自動走行のしくみ(runDir)は core に残っているが、画面からは送っていない。テストで「指を離したら、それ以上進まない」ことを見るようにした。

横向きの画面では、ボタンが大きくなったぶん定型文が 1 つ入りきらなくなった(テストが見つけた)。横向きだけボタンを少し小さくして戻した。

v0.5.2 ベルトは「1 マスだけ乗れて、押し戻される」
DECISION

ベルトコンベアの逆走を、乗ること自体を止める形から乗れるが押し戻される形に変えた。下流から 1 マス目までは進めて、そこで流れに押し返される。ベルトの上で流れと逆を押し続けても、流れが勝つ。

WHY / 仮説

「1 マス目までは進めて、そのあと押し戻されるように」と言われた。止めてしまうと見えない壁になり、なぜ動かないのかが分からない。押し戻されるなら、理由が目で分かる。

RESULT

道さがし(CPU・タップの行き先・順位の距離)のほうは今までどおり「逆走は通れない」と数える。乗れても先へ進めないので、道としては変わらない。

v0.5.2 操作はゲーム中にも変えられる
DECISION

ポーズ画面に操作の選択を置いた。閉じるとその場で切り替わる。

WHY / 仮説

操作は 4 つあるが、どれが手に合うかは遊んでみないと分からない。始める前の画面まで戻らないと変えられないと、試す気になりにくい。

RESULT

切り替えたときは押しっぱなしと行き先を捨てる(十字を押したまま変えて、歩き続けたままになるのを防ぐ)。ポーズ画面の札は地の色を敷いた——透かしたら、後ろの盤面が札の中に透けて読めなかった。

v0.5.1 タップの「おまかせ」をやめる。操作は 4 つ、タップの中身は 2 つに絞る
DECISION

タップ操作の下にあった「タップの歩き方(おまかせ / そのまま)」という設定をやめ、タップは「そのまま」だけにした。歩き方はタップ(指した方へまっすぐ)とタップ完全自動(面全体で道を探す)の 2 つになり、設定の段が 1 つ消えた。

操作そのものは 4 つ(タップ・タップ完全自動・スワイプ・十字ボタン)のまま。

WHY / 仮説

「おまかせ」は部屋の中で最短の道を探し、トゲと矢が通り過ぎるのを待ち、敵や落とし穴の手前で止まっていた。プレイヤーがやることが「どこへ行くか指す」だけになり、完全自動とほとんど区別がつかなかった。

同じことをする選択肢が 2 つ並んでいると、選ぶ手間だけが増える。「自分で操る」と「ぜんぶ任せる」の 2 つに割り切るほうが、何が違うか一言で言える。

RESULT

設定画面から 1 段まるごと消え、歩き方を表す型(TapWalk)も 3 つから 2 つになった。「おまかせ」を前提に書いていたテスト 3 本は、まっすぐ歩く前提に書き直した。

オンラインでは歩き方を相手に送っているので、古い版が送ってくる「おまかせ」は「そのまま」に読み替える(知らない名前で落ちない)。

v0.5.1 操作できる面は、枠で囲って「ここを触る」と見せる
DECISION

スワイプ操作のときに画面の下半分に出している案内を、薄めるのをやめて枠で囲った。中身(手の絵と「スワイプで進む」「タップで撃つ」)は変えていない。

WHY / 仮説

「スワイプを選んだのに、スワイプのボタンが出てこない」と言われた。実際には案内は出ていたが、文字と絵を透明度 0.55 で描いていて、コントラスト比が 2.7 しかなかった(本文の下限は 4.5)。暗い地の上では、何も無い黒い帯に見えていた。

ボタンを足す案は採らなかった。スワイプは「画面のどこでも指 1 本」が売りで、押す場所を作ると意味が変わる。以前、4 方向の矢印と丸を描いたときも「押す場所に見える」と言われている。

枠なら「この中ぜんたいが操作の面」という意味になり、押す場所には見えない。

RESULT

案内の色は薄めない決まりにして、テストで色と枠の有無を見るようにした(また薄めたら落ちる)。十字ボタンのほうは出ていることも動くことも確かめた。

2026-09-23 名前に合わせて識別子と配信先を nukero に揃え、旧 URL は捨てる
DECISION

App Store に登録する Bundle ID と SKU を jp.nzw.nukero にした。それに合わせて Android の applicationId と、nzw.jp の配信フォルダも /static/app/nukero/ に移した。旧 /static/app/mazerush/ からのリダイレクトは置かず、消した。

Dart のパッケージ名(package:mazerush/…)だけは旧名のまま残した。

WHY / 仮説

開発中の名前(Maze Rush)から NUKERO! に変えたあと、識別子だけが旧名のまま残っていた。Bundle ID はあとから変えられないので、ストアに出す前——つまり今——でなければ直せない。

旧 URL を残さなかったのは、β を公開したのが同じ日(2026-09-23)で、外に出回っている期間が数時間しかなかったため。リダイレクトを足すと nginx の設定が 1 つ増え、消す理由を覚えている人がいなくなる。

Dart のパッケージ名を変えなかったのは、全ファイルの import に響くのに、外からは一切見えないため。変える理由が「気持ち悪い」しかないものは変えない。

RESULT

サイト側で旧パスを指していたのは 3 ファイル 5 か所だけだった(開発ノート・アプリ一覧・Web 版の <base href>)。Web 版は --base-href を変えて作り直した。

同じ日に Android の APK もβ配布に足した。署名は他のアプリと共通のリリース鍵で、apksigner で v3 署名と CN=nzw を確認してから置いた。鍵が無いときは release ビルドを止めるようにしてある——debug 鍵で配り始めると、あとで共通鍵に移る版が上書き更新できなくなり、利用者にアンインストールしてもらうしかなくなるため。

iOS は IPA(22.0MB)まで作れることを確かめた。App Store の Capabilities はひとつも有効にしていない——マイクと音声認識は Info.plist の説明文で足り、オンライン対戦は素の WebSocket なので、entitlement を要求するものが無い。

2026-09-23 β版は Web だけ先に出す。通信は手元の 2 台では確かめきれない
DECISION

ストアを待たずに、Web 版だけを β として /static/app/nukero/web/ に置いた。

URL を開けば部屋が作れる。相手には 4 文字の部屋コードを渡すだけで、インストールもアカウントも要らない。

ストア版(iOS / Android)は 1 本も出していない。リリース日は決めずに、β で通信の壊れ方を拾ってから出す。

WHY / 仮説

オンラインの作りは、手元の 2 台では確かめきれない。同じ家の回線・同じ機械に立てた 2 つのブラウザでは、片道 10 ms でパケットの落ちない世界しか見ていない。

携帯回線・別の国・NAT の内側・端末を裏に回したときは、人に遊んでもらわないと出てこない。インストールが要ると、その人数が集まらない。

ゲームの中身は、一発アウトと片手の見直しで形が決まった。残って未検証なのは通信だけなので、そこだけを試す出し方にする。

RESULT

2026-09-23 に公開。本番の中継へ実際につないで、ping/pong の往復が中央値 10.5 ms、in が 11.9 ms、snap が 11.4 ms で通ることを確かめた(下の「実測」)。

分かっていて残した穴は 3 つ。ゲストの回線が一瞬切れると始まった部屋には戻れない(4010)、ホストの端末が裏に回ると部屋ごと止まる、API を再起動すると遊んでいる部屋が全部消える。

記録や順位を残す仕組みは入れない。β の間は、部屋を作って遊ぶところまで。

2026-09-23 ゲストの入力は「押しっぱなし」「1 回きり」「行き先」の 3 つに分けて送る
DECISION

押しっぱなしの向き(h)と撃つ(f)は、変わったときだけ送る。

1 回きりの合図(十字の 1 押し tp、スワイプ sw、タップで撃つ st)は、それぞれ別の欄で、その 1 回だけ送る。

タップで歩く操作は、行き先のマス ta: [x, y] をそのまま送る。道を探すのはホストの仕事。

WHY / 仮説

片手で遊ぶ形に見直して、操作がスワイプとタップになった。どちらも「1 回きりの合図」で、押しっぱなしとは性質が違う。

1 回きりの合図を f の 1 → 0 で表すと、2 通が同じフレームに届いたときにホストは「押して離した」を 1 tick の中で見ることになり、撃たずに終わる。欄を分ければ、同じフレームに 2 通届いても両方が 1 回ずつ効く。

行き先をそのまま送るのは、ゲスト側で道順を十字の連打に直すと、壁が壊れて迷路が変わった瞬間にホストと別の道を歩くため。ホストの GameState は崩れた壁を知っている。

RESULT

1 通は 34 バイト(向きだけ)から 63 バイト(行き先と歩き方つき)。サーバーが席番号を足して 40〜69 バイト。

ゲストは操作のしかたを自由に選べる。2026-09-23 の時点で 4 つ(タップ・タップ完全自動・スワイプ・十字ボタン。既定はタップ)あるが、ホストはこの 8 つの欄を読むだけで、どれで遊んでいるのかを知らない。

操作を増やしても、ホストの進行のコードは 1 本のまま(core/)。1 台で遊ぶときと同じ入力の形に入る。

2026-09-23 「完全自動」は行き先の部屋番号を送る。歩き方の設定も入力に相乗りさせる
DECISION

タップ完全自動では、全体マップの部屋をタップすると行き先になる。送るのは部屋の番号 1 つ(tr)で、そこまでの道はホストが探す。

タップの歩き方(そのまま/完全自動)はゲストごとの設定なので、行き先と一緒に tw で送る。既定の「そのまま」は欄ごと省く(v0.5.1 で「おまかせ」をやめた。下の意思決定ログ)。

WHY / 仮説

行き先が部屋なら、道さがしは面全体に広がる。ゲスト側で解いて十字の連打に直すと通数も増えるし、壁が崩れて迷路が変わった瞬間にホストと別の道を歩く。

歩き方を写しや start に入れなかったのは、席ごとに違う設定だから。ホストは全員ぶんの進行を回すが、どう歩かせるかは各ゲストの手元の設定で決まる。入力に相乗りさせれば、途中で切り替えてもそのまま効く。

RESULT

部屋をタップして 1 通 54 バイト(中継後 60 バイト)。部屋をまたぐ長い移動でも、送るのはこの 1 通だけ。

ホストが読む欄は 8 つになったが、操作のしかたを 4 つに増やしても写しの形は変わっていない。

2026-09-23 走っている間に変わるものだけは写しに載せる(動くゴール・壁の傷)
DECISION

写しに gl(いまのゴールのマスの番号)を足した。ゲストは生成した迷路の stage.goal ではなく、届いた gl を見る。

敵のしびれ(撃たれて止まっている残り)も足した。

WHY / 仮説

9・10 面のゴールは、泥棒が星を持って逃げる。迷路はシードから各端末で作れるが、生成した時点のゴールは泥棒が最初にいた場所で、走り出したあとは合わない。

ゲストが stage.goal を見ていると、全体マップの矢印も順位の距離も、誰もいない場所を指す。

マスの番号は整数 1 つで、写し全体で 5〜9 バイト。差分にする理由がない。

RESULT

泥棒の面(10、協力 4 人)の写しは中央値 606 バイト・最大 726 バイト。敵の多い面 8(649 / 782 バイト)より小さい。

「シードから作れるものは送らない」の例外は、いまのところゴールと壁の傷だけ。どちらも走っている間に変わる。

2026-09-21 敵と迷路は前の 2 本から持ってくる。ただし決定的に書き直す
DECISION

敵の巡回の決め方と見つける距離 8 は BOM MAN ARENA から、迷路の掘り方(深さ優先の穴掘り法)とゴールからの距離場は Algo Derby から持ってくる。

そのかわり乱数は作り直す。Dart の Random(seed) をやめ、xorshift32 を自分で書いた。DateTime も使わない。

WHY / 仮説

敵の動きも迷路も、既に 2 度作って形が分かっている。3 度目に新しく考える理由が無い。

乱数だけは引き継げなかった。Random(seed) が Dart VM と dart2js で同じ列を返す保証は無く、同じ端末でしか同じ迷路にならない。掛け算を使う実装も、Web では桁が落ちる。

Algo Derby はそもそもシードを持たず、同じレースが二度と再現できない作りだった。1 人で遊ぶぶんにはそれで面白いが、迷路を送らずシードだけ配る形(下の決定)はその上に建てられない。

RESULT
  • 迷路の生成を 8 面 × 1000 シードで回して確かめられるようになった。
  • 持ってきた側も、そのままでは使えなかった。BOM MAN ARENA が毎フレーム引き直していた経路は 20 tick ごとのキャッシュに、縦横に並んでいれば壁越しでも見つかっていた視界は 1 マスずつの遮蔽判定に書き換えている。
  • CPU は迷路を知らない作りにした(自分が見たマスだけを通れる道とみなす)。全体を知って最短で進ませたら「強すぎる」となったため。
2026-09-21 進行はホストの端末に置き、サーバーは部屋と席と受け渡しだけにする
DECISION

レースを進めるのはホストの端末だけ。入力を当て、敵・弾・当たり判定・CPU を動かす。

サーバーは部屋と席を管理し、メッセージの種類と送り手の席を見て行き先を決めるだけ。ホストのメッセージは受け取った文字列のまま配る。

ホストが抜けたら部屋を閉じる。

WHY / 仮説

進行のコードは core/ に 1 本だけある。サーバーを正にすると、同じ進行を Python にもう 1 本書き、2 本を一致させ続けることになる。

ホストの端末なら、1 台で遊ぶときと同じコードがそのまま動く。

サーバーは既にある nzw.jp の API に同居させた。nginx は既存の /v1/ws/ の設定(Upgrade の中継と、待ち上限 3600 秒)のままで通った。

RESULT
  • 中継は 203 行で済んだ。
  • ホスト自身の操作には通信の遅れが乗らず、ゲストには往復ぶんが乗る(下の「実測」)。対戦ではホストが有利になる。
  • ホストの代わりはいないので、ホストの回線が切れると部屋ごと終わる。
  • オンラインではポーズもできない(止めると相手の時間まで止まる)。
2026-09-21 迷路は送らない。core/ を決定的に保ち、シードだけを配る
DECISION

レース開始の start で送るのは、ステージ番号・シード・HP・人数・空き席だけ。迷路・敵の初期配置・仕掛けは、各端末が同じシードから作る。

そのために core/ では次を守る。

  • DateTime とシードなしの Random() を使わない
  • 時間は実時間ではなく tick(60/秒)で数える
  • ループは経過時間を貯めて固定ステップで回し、1 フレームを 1/60 秒とはみなさない
WHY / 仮説

迷路を送らなければ、写しには動くものだけが残る。

同じ設定・シード・入力なら同じ結果になる性質は、テストの再現にも要る(迷路の生成を 8 面 × 1000 シードで回している)。

1 フレーム = 1/60 秒にしないのは、Synapse Link がそうしていて、120Hz の端末で 2 倍速になっていたため。

RESULT

写しは 1 回 195〜663 バイトに収まった。

その代わり、ゲストの端末も迷路全体(ゴールの位置を含む)を持っている。霧(誰かが入った部屋だけを見せる)は描画の側で隠しているだけ。

2026-09-21 写しは差分にせず、動くものを毎回全部送る。ゲストは届いたら丸ごと上書きする
DECISION

ホストは 3 tick ごと(20 回/秒)に、次のものを 1 通にして送る。前回との差分にはしない。

  • 人・敵・矢・弾の状態
  • 開いた部屋(1 つの整数のビットに詰める。部屋はステージ 8 の 4×3 = 12 が最大なので、31 ビットに収まる)
  • その間に起きた出来事(効果音と演出用)

行はキー付きのオブジェクトではなく、数の並びにした。

ゲストは届いた写しで状態を丸ごと上書きする。何通か固まって届いたら、次の tick で順に全部を当てる。

WHY / 仮説

毎回全部を送れば、1 通が遅れても次の 1 通で元に戻り、ずれが溜まらない。前の写しを受け取れていたかを気にしなくてよい。

固まって届いた写しを最後の 1 通だけにせず全部当てるのは、途中の写しに載っている出来事(効果音)を落とさないため。

RESULT
  • 最大 663 バイト × 20 回で、ホストの上りは 1 秒に約 13KB。サーバーはこれをゲストの人数ぶん複製して送る。
  • 1 通の上限(16KB)には 24 倍の余裕がある。
  • 実際に踏んだのは大きさではなく包み方。写しを種類と同じ階層に広げたら、写しの t(tick の番号)が種類を上書きし、サーバーに全部捨てられた(下の「踏んだこと」)。
2026-09-21 ゲストは押したボタンが変わったときだけ送り、写しの合間は見た目だけを 4 tick まで進める
DECISION

ゲストは毎 tick 入力を見るが、送るのは次のときだけ。1 通は 34 バイトほど。

  • 押している向きか、撃つボタンが変わったとき
  • 1 回きりの合図(十字の 1 押し。2026-09-23 にスワイプとタップを別の欄に分けた。上の決定)
  • 押しっぱなしの念押しに 1 秒ごと

写しと写しの間は、移動の補間と罠の周期だけを手元で進める。最後の写しから 4 tick を過ぎたら、次が届くまで進めない。

自分の操作を先に動かして見せる予測はしていない。

WHY / 仮説

毎 tick 送ると 1 人 1 秒 60 通になる。サーバーの上限は 1 人 1 秒 90 通で、超えた分は黙って捨てられる。

見た目を 4 tick で止めるのは、ホストより先に時計が進むと、トゲの出入りがホストの画面と食い違って見えるため。

RESULT

ゲストが押してから自分の画面で動くまでは、開発機からの実測と tick の区切りから見積もって約 21〜104 ms(下の「実測」)。

携帯回線では測っていない。

2026-09-21 アカウントは作らせず、4 文字の部屋コードと上限だけで守る。部屋はメモリにしか置かない
DECISION

部屋はホストの端末が決める 4 文字のコードで作る。使う文字は、見間違えやすい O/0・I/1・S/5・B/8・Z/2 を抜いた 26 文字(約 46 万通り)。

席はサーバーが割り当て、ゲストの in と全員の chat にはサーバーが席番号を付け直す。

上限は次のとおり。

  • 部屋は 200 まで、席は対戦 2・協力 4 まで。超えると新しい接続を断る(4503・4011)
  • 1 通 16KB、1 人 1 秒 90 通まで。超えた分は切断せずに捨てる
  • 始まった部屋には入れない(4010)
WHY / 仮説

中継そのものは 3 か月前のグループチャットから持ってきた(部屋ごとに接続を持って自分以外へ配る骨格と、断る理由を close コードで返す作法)。ただしログインもデータベースも引き継がなかった。あちらは会話を残すために両方が要るが、こちらは残すものが無い。

アカウントが無いので、荒らしへの備えは数の上限で行う。

部屋コードは人が読んで打ち込むので、見間違えやすい文字を最初から使わない。グループチャットの部屋番号はサーバーが 6 文字で採番していたが、こちらは端末が 4 文字で作って、ぶつかったら引き直す形にした。

RESULT
  • 席番号はサーバーが付けるので、ゲストが別の席を名乗って他人の操作やメッセージを送ることはできない。
  • 保存するものが無いので、データベースは要らない。
  • その代わり、API を再起動すると遊んでいる部屋は全部切れる(バックエンドをデプロイするたびに起きる)。
  • API は 1 プロセスで動いているので、部屋がプロセスをまたぐことはない。プロセスを増やすと部屋が分かれる。
2026-09-22 声は端末の中で文字にし、サーバーには文字だけを流す。定型文はコードで送る
DECISION

マイクのボタン(V キー)を押している間だけ聞き、離したら OS・ブラウザの音声認識で文字にして送る。

定型文 6 つはコード(q)だけを送り、受け取った側が自分の言語で出す。

1 通 40 文字・1 秒に 1 通の上限は、端末とサーバーの両方に同じ値で置いた。1 台で遊ぶときは出さない。

WHY / 仮説
  • 押している間だけにしたのは、聞きっぱなしにすると、まわりの声やゲームの効果音まで文字にして送ってしまうため。
  • 音声そのものを流さなければ、サーバーは文字の中継で済む。音声を Apple・Google へ送る OS はあるが、それは OS の側で決まり、nzw.jp には届かない。
  • 端末でも同じ上限で切るのは、送った本人の画面にだけ長い文が出て、ほかの人の画面と食い違わないようにするため。
  • 40 文字は Python の str[:40] と同じくコードポイントで数える(Dart の length は UTF-16 の単位なので使わない)。
RESULT
  • Firefox には音声認識が無く、マイクは「音声は使えません」になって定型文だけで遊べる。
  • Web では 2 回目から話せなくなる不具合を踏んだ(下)。
  • 自動操作の Chrome は音声認識の結果を返さないので、声の通しは実機でしか確かめられない。
Measurements

実測:写しの大きさと遅れ

写しの大きさは、core/ を CPU(Lv4)で回し、実際に送る形({"t":"snap","d":写し} の JSON 全体)にして数えた。ステージごとに 5 シード、ゴールか 90 秒まで。

ステージ敵の数対戦 2 人(中央値 / 最大)協力 4 人(中央値 / 最大)
10204 / 244 B287 / 321 B
43335 / 396 B427 / 517 B
88555 / 653 B649 / 782 B
9(泥棒)5425 / 502 B518 / 642 B
10(泥棒)7514 / 606 B606 / 726 B

2026-09-23 のβ版のコードで計測。大きさは人と敵の数でほぼ決まる(1 人・1 体あたり約 40〜45 バイト)。前日(9-22)の計測より面 8 で 67 バイト増えたが、足した欄(gl が 9 バイト、敵 1 体につきしびれの 1 つ)は 30 バイトほどで、残りは面ごとの敵の数を見直した分(面 8 は 7 体 → 8 体)。

ゲストが送る in は、欄を分けたあとも 70 バイトに収まる。

送るとき足す欄大きさ(サーバーが席を足したあと)
向きを押した・離したなし34 B(40 B)
スワイプ 1 回"sw":"r"44 B(50 B)
タップで撃つ"st":142 B(48 B)
タップで行き先を指す"ta":[12,7]47 B(53 B)
タップで行き先を指す(そのまま)"ta":[12,7]+"tw":"straight"63 B(69 B)
全体マップの部屋をタップ(完全自動)"tr":5+"tw":"full"54 B(60 B)

遅れは、開発機から本番の中継(wss://nzw.jp)へホストとゲストを同じ機械からつなぎ、in と snap を 40 回ずつ通して測った。同じ機械の時計で測れるので、片道(端末→サーバー→端末)がそのまま出る。

ゲストが押してから自分の画面で動くまで待つもの値
押してから送るまでゲストの次の tick0〜17 ms
ゲスト → サーバー → ホストin(34 バイト)中央値 11.9 ms(p95 61.1 ms)
ホストが写しに載せるまで次の tick で入力を当て、3 tick ごとの写しに載る0〜50 ms
ホスト → サーバー → ゲストsnap(約 650 バイトで計測)中央値 11.4 ms(p95 60.4 ms)
届いてから映すまでゲストの次の tick0〜17 ms
合計約 23〜107 ms。ホスト自身は 0

2026-09-23 の計測。ホストとサーバーの往復(ping / pong)は中央値 10.5 ms、p95 68.2 ms。chat(定型文をゲストからホストへ)は中央値 13.6 ms、p95 65.4 ms(1 秒に 1 通までなので 20 回)。携帯回線・海外からは測っていない。

中央値は前日とほとんど変わらないが、p95 は 11〜12 ms から 60 ms へ開いた(同じ開発機・同じ回線、時間帯だけが違う)。20 回に 1 回は写しの間隔(50 ms)より回線の揺れが大きい、ということになる。β で人の回線を見たいのはここ。

中央値で見るかぎり、幅がいちばん大きいのは回線ではなく写しの間隔(3 tick = 50 ms)の待ちだった。写しを 1 秒 60 回にすれば縮むが、ホストの上りとサーバーの複製も 3 倍になる。

ただし p95 まで見ると回線の方が大きくなる日がある。どちらを削るかは、β で人の回線の数字を見てから決める。

Pitfalls

踏んだこと

壊れ方原因今の形
写しがゲストに届かない写しを {"t":"snap", ...写し} と種類と同じ階層に広げた。写しにも t(tick の番号)があり、種類を上書きした。サーバーは「ホストが送る種類ではない」として捨てた写しは d の下に包む。「写しには t がある」ことをテストで固定した
release の APK でオンラインにつながらないAndroid の雛形は INTERNET を debug と profile にしか書いておらず、使っているプラグインも足さないsrc/main に INTERNET を書く。マイクの権限と音声認識の問い合わせ先も自分で書く
メッセージが 4 秒遅れて届くように見えたPlaywright で 1 つのブラウザに 2 ページを開くと、後ろのページが 1〜3fps に落ち、キー 1 回に数秒かかっていた。通信ではなく試し方の問題2 人ぶんはブラウザを 2 つ起動して試す。Web は開始直後の数秒が重いので、10 秒ほど待ってから測る
撃ったのに撃っていないことがある(作り直す前の形)スワイプとタップの 1 回きりの合図を、押しっぱなしの f の 1 → 0 で表していた。2 通が同じフレームに届くと、ホストは「押して離した」を 1 tick の中で見て撃たない1 回きりの合図は別の欄(sw / st)で送る。欄が別なら同じフレームでも両方が 1 回ずつ効く
ゲストのゴールの矢印が、誰もいない場所を指すゴールを持って逃げる泥棒の面で、ゲストが生成した迷路の stage.goal(泥棒が最初にいた場所)を見ていたいまのゴールを写しの gl で送る。進行・描画・順位の距離はどれも GameState.goal を見る
Web で 2 回目から話せない音声認識は stop() のあと終わりの知らせが届くまで、次の start() が InvalidStateError で失敗する。知らせが来ないこともある聞き始める前に abort して 300 ms 待つ(abort なら 50 ms ほどで知らせが来た)。始まらなくても 3 回続くまでは「使えない」扱いにしない
Milestones

これまでの歩み

方針2026-10-10

ストア版のリリース日を未定にする(10 月の目標を外した)/β で通信の壊れ方を拾い切るまで、日付を置かない

v1.0.0未定

iOS / Android のストア版(どちらも未リリース)

v0.6.02026-09-23

やられるのは 3 回まで(3 秒数えて再開・3 回目でゲームオーバー)/世界地図をビルの断面に/壊せない壁で弾かれる火花

v0.5.22026-09-23

スワイプは押さえている間だけ進む(離すと止まる)/十字ボタンの当たり判定を広げる/ポーズ画面で操作を変えられる/ベルトは 1 マス乗れて押し戻される

v0.5.12026-09-23

タップの「おまかせ」をやめて 2 種類に/スワイプの操作面を枠で囲って見えるように/Android の APK を v0.5.1 に

v0.5.02026-09-23

Web 版をβとして公開/Android の APK も配布(Bundle ID と配信先を nukero に統一)/地下 10 階 × 10 面の全 100 面と世界地図/入力を押しっぱなし・1 回きり・行き先の 3 つに分ける(sw・st・ta・tr・tw)/動くゴールを写しに載せる(gl)/操作を 4 つにして既定をタップに/見た目をファミコン風の 8bit へ

開発中2026-09-22

名前を NUKERO! に/オンラインで声と定型文のメッセージ/中継を本番の API に載せ、release の APK で INTERNET が抜けていたのを直す

初版2026-09-21

4 つの遊び方/オンラインはホストが進め、nzw.jp が受け渡すだけ/core/ は純 Dart で決定的(テスト 58 本)

前史2023-09 / 2025-12 / 2026-03

爆弾ゲームを当時のまま置き直す/Algo Derby で迷路をレースにする/BOM MAN ARENA で作り直す(系譜)

Roadmap / Open Questions

これから

β で見たいのは、ほとんどが通信のこと。ゲームの中身より先に、これが分からないとストアに出せない。

未決事項:迷路とほかの人が入った部屋を全端末が持っているので、改造した端末ならゴールが見える。いまは記録や順位を残す仕組みが無い。