Claude Code に手伝ってもらって Roblox ゲーム「+1 Balloon: Float To Space」を作り、2026-11-04 に公開しました。その間に踏んだ罠と、いま動いている対策を、実物のコードで書きます。
| 項目 | 値 |
|---|---|
| ツール | Rojo 7.7.1 / Lune 0.11.1 / Open Cloud(Python の urllib から) |
| ソース |
src/ の Luau が約1.6万行(翻訳ファイルを除く) |
| テスト |
tests/logic.spec.luau 492件、tests/locale.spec.luau 1,932件の整形チェック |
| 公開版 | version 38 |
先に断っておくと、公開したばかりです。本番の負荷や、本番サーバーでだけ動く処理は検証できていません。未確認のものは、そう書きます。
指摘の中身やゲームの変わり方は note に書きました: https://note.com/views_of_life/n/naa05fee49e48
どの罠が、どこで見つかるか
| # | 罠 | 型チェック | Lune のテスト | 見つかる場所 |
|---|---|---|---|---|
| 1 | local 上限200 | 通る | 通る | コンパイル検査 |
| 2 |
ChatVersion が旧方式 |
通る | 通る | Studio の実行 |
| 3 | スコープ不足の403 | - | - | Open Cloud の応答 |
| 4 | FBX の Content-Type | - | - | Open Cloud の応答(400) |
| 5 | Blender と Roblox の単位 | 通る | 通る | 目視 |
| 6 | Lune に script と game が無い |
- | 読み込みエラー | テストの実行 |
| 7 | 有料ランダムの制限 | 通る | 通る | 設計(実行しても気づきにくい) |
| 8 | Studio を前面に出せない | - | - | 誤クリックの実害 |
| 9 | 絵文字フォントの豆腐 | 通る | 通る | 目視 |
1. トップレベルの local が200を超えると、型チェックもテストも通るのに読み込めない
何が起きたか
version 26 で src/client/UI.luau のトップレベルの local が217個になり、モジュールごと読み込めず、初期画面が空になりました。Roblox が出したのは Out of local registers です。
手元の控えから、その時の UI.luau を取り出してコンパイルすると再現します。
# Lune 0.11.1 の luau.compile
syntax error: 1927: Out of local registers when trying to allocate steerL: exceeded limit 200
# luau-compile.exe --binary
UI.luau(2364,16): CompileError: Out of local registers when trying to allocate popIn: exceeded limit 200
同じファイルなのに、落ちる行が道具によって違います。 気になったので、小さいファイルで試しました。
# local を n 個並べるだけのファイルを作る
def gen(n):
return "".join(f"local v{i} = tostring({i})\n" for i in range(n)) + "return v0\n"
| ファイル | Lune luau.compile
|
luau-compile.exe |
luau-analyze.exe |
|---|---|---|---|
local v = tostring(i) を200個 |
通る | 通る | 通る(未使用の警告のみ) |
local v = tostring(i) を201個 |
落ちる | 通る | 通る(未使用の警告のみ) |
local v = i(定数)を201個 |
通る | 通る | 通る(未使用の警告のみ) |
実物の UI.luau(壊れた版) |
落ちる(1927行) | 落ちる(2364行) | (型チェックは当時通過) |
定数だけの local は上限に数えられないようで、未使用の local の扱いも道具で違います。Roblox 本体がどちらに近いかは確かめていないので、200ちょうどを狙わず、余裕を持たせてください。
テストで見つからなかったのは、tests/logic.spec.luau が Config・Logic・Rng・Num しか読まないからです。UI.luau は game のサービスを使うので、そのままでは Lune のテストから読めません。
検査(tools/compile_check.luau)
-- Compile every source file the way Roblox will. Type checks and logic tests miss compile-only errors such as
-- "Out of local registers" (more than 200 locals in one function), which stop a whole module from loading.
-- usage: lune run tools/compile_check.luau (exits 1 on any failure)
local fs = require("@lune/fs")
local luau = require("@lune/luau")
local process = require("@lune/process")
local failed = 0
local function walk(dir)
for _, name in fs.readDir(dir) do
local p = dir .. "/" .. name
if fs.isDir(p) then
walk(p)
elseif name:match("%.luau$") then
local ok, err = pcall(luau.compile, fs.readFile(p))
if not ok then
failed += 1
print("FAIL " .. p .. " " .. tostring(err):match("^[^\n]*"))
end
end
end
end
walk("src")
print(failed == 0 and "compile ok" or (failed .. " file(s) failed to compile"))
process.exit(failed == 0 and 0 or 1)
実行せずにコンパイルだけするので、game が無い Lune でも全ファイルを検査できます。
公開スクリプトに組み込む(tools/publish.py)
def main():
reset_dev_flags()
version_type = sys.argv[1] if len(sys.argv) > 1 else "Saved"
assert version_type in ("Saved", "Published"), version_type
# a module that does not compile shows nothing in game, so never upload one
subprocess.run([LUNE, "run", "tools/compile_check.luau"], cwd=ROOT, check=True)
subprocess.run([ROJO, "build", str(ROOT / "default.project.json"), "-o", str(OUT)], check=True)
s = load_secrets()
url = (
f"https://apis.roblox.com/universes/v1/{s['universeId']}/places/{s['placeId']}/versions"
f"?versionType={version_type}"
)
status, body = call("POST", url, s["apiKey"], OUT.read_bytes(), "application/xml")
check=True なので、検査が落ちるとアップロードまで進みません。
減らし方
関連する部品を表にまとめます。表の中身は何個でも local を消費しません。300個のフィールドを入れたファイルも通りました。
-- src/client/UI.luau
local He = {} -- grouped to stay under Luau's 200-locals limit
He.blue = Color3.fromRGB(120, 220, 255)
He.cells = {}
-- (中略)
He.state = nil -- { n, max, next, regen, at } as last sent by the server
function He.now()
これで217個から170個へ減らしました。新しい状態は表 Ux に足す決まりにしています。
2. Rojo で TextChatService を書くと ChatVersion が旧方式になる
TextChatService の下だけを Rojo で書くと、既定チャンネルが消えました。version 9 はこれで壊れていました。
rojo build の出力を比べると理由が分かります。プロパティを書かないと、rbxlx に ChatVersion そのものが出ません。読み込んだ Studio では旧方式(0)になりました。
<Item class="TextChatService" referent="0">
<Properties>
<string name="Name">TextChatService</string>
</Properties>
<token name="ChatVersion">1</token>
<bool name="CreateDefaultTextChannels">true</bool>
対策は明記です(default.project.json)。
"TextChatService": {
"$className": "TextChatService",
"$properties": {
"ChatVersion": "TextChatService",
"CreateDefaultTextChannels": true,
"CreateDefaultCommands": true
},
型チェックにもテストにも出ないので、Studio の自動テストで RBXGeneral まで届くかをログに出しています(src/client/AutoTest.luau)。
local tcs = game:GetService("TextChatService")
local ch = tcs:WaitForChild("TextChannels", 10)
ch = ch and ch:WaitForChild("RBXGeneral", 10)
log("chat version", tcs.ChatVersion, ch ~= nil)
log("chat send", ch and pcall(ch.SendAsync, ch, "hello from autotest"))
3. Open Cloud のキーに ordered-data-store が無く403 → サーバー内で一度だけ消す
世界ランキングに、Studio のテストで作った自分の記録が載っていました。Open Cloud で消そうとしたら403です。API キーに付けたのは universe・universe-places・universe-thumbnail・game-passes・developer-products・assets で、ordered-data-store がありませんでした。
外から消す代わりに、ゲームのサーバー内で消す設計にしました。2本立てです。
(1) 本番サーバーが立ったときに一度だけ。 UpdateAsync で印を取れたサーバーだけが消します(src/server/Leaderboard.luau)。
function Leaderboard.purgeOwnerOnce(purgeId)
if RunService:IsStudio() or game.CreatorType ~= Enum.CreatorType.User then
return
end
task.spawn(function()
local ok, meta = pcall(DataStoreService.GetDataStore, DataStoreService, "BoardMeta_v1")
if not ok or not meta then
return
end
local done = false
pcall(function()
meta:UpdateAsync(purgeId, function(old)
if old then
return nil
end
done = true
return os.time()
end)
end)
if done then
Leaderboard.forget(game.CreatorId)
end
end)
end
(2) 持ち主が入ったときにも一度だけ。 このゲームは入るたびに、書き込みに失敗した記録を送り直します。これを止めないと、消した最高記録を自分で書き戻してしまいます(src/server/Game.luau)。
local isOwner = game.CreatorType == Enum.CreatorType.User and player.UserId == game.CreatorId
if isOwner and not RunService:IsStudio() and p.boardPurge ~= BOARD_PURGE then
p.boardPurge = BOARD_PURGE
Leaderboard.forget(player.UserId)
else
-- v16: records a board missed (a throttled or failed write) are sent again once per join
if p.bestAlt > 0 then
Leaderboard.submit(player, p.bestAlt, "altitude")
end
forget の中身は RemoveAsync です。また消したくなったら BOARD_PURGE の数字を上げます。そもそも載らないように、Studio からは送りません。
-- Leaderboard.forget
pcall(store.RemoveAsync, store, tostring(userId))
-- Leaderboard.submit
-- v25: a Studio test (rich dev profiles) never reaches the real boards
if RunService:IsStudio() then
return
end
未確認の点: 消えるのは公開後に最初の本番サーバーが立ったときで、実際に消えたかはまだ確かめていません。
4. FBX のアップロードは Content-Type を model/fbx にしないと400
Open Cloud の Assets API に FBX を multipart で送るとき、ファイルの部分の Content-Type が model/fbx でないと400が返ります。
Python の mimetypes は FBX を知りません。私の Windows では .ogg も空文字でした。
>>> mimetypes.guess_type('a.fbx')
(None, None)
>>> mimetypes.guess_type('a.ogg')
('', None)
なので自前の表を先に見ます(tools/roblox_api.py)。
# Types Open Cloud expects that Python's mimetypes table doesn't know.
EXTRA_TYPES = {".fbx": "model/fbx", ".ogg": "audio/ogg"}
def multipart(fields, files=None):
boundary = uuid.uuid4().hex
parts = []
for k, v in fields.items():
parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n".encode())
for k, path in (files or {}).items():
path = Path(path)
ctype = EXTRA_TYPES.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
parts.append(
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"; filename=\"{path.name}\"\r\n"
f"Content-Type: {ctype}\r\n\r\n".encode()
+ path.read_bytes()
+ b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
アップロードの本体は assetType を拡張子から決めて、返ってきた操作をポーリングして assetId を取ります。上げた物は assets/uploaded.json に名前とサイズで記録し、同じ物は飛ばして、最後に src/shared/Assets.luau を生成します。ゲーム側は ID を手で持ちません(tools/upload_assets.py)。
KINDS = {".ogg": ("Audio", "audio/ogg"), ".mp3": ("Audio", "audio/mpeg"), ".png": ("Decal", "image/png"), ".fbx": ("Model", "model/fbx")}
5. Blender → Roblox は単位が約17倍になる
Blender で1単位=1スタッドのつもりで作っても、読み込むと景色のモデルが約17倍になりました。理由までは追っていません。対策は、作ったときの大きさを Blender 側で書き出し、ゲーム側で戻すことです。
Blender 側(tools/blender_scenery.py)。
dims[aid] = round(max(hi[i] - lo[i] for i in range(3)), 3)
# (中略)
# The importer changes units, so the game rescales each model to its authored size (studs = Blender units).
with open(os.path.join(ROOT, "src", "shared", "SceneryDims.luau"), "w", encoding="utf-8") as f:
f.write("-- Generated by tools/blender_scenery.py: longest side of each scenery model as authored, in studs.\nreturn {\n")
for aid, d in dims.items():
f.write(f"\t{aid} = {d},\n")
f.write("}\n")
ゲーム側(src/shared/Scenery.luau)。読み込んだ実寸を測り、比で ScaleTo します。
-- the importer changes units: bring the model back to its authored size (studs), then apply scale
local authored = Dims[stem]
local native = math.max(size.X, size.Y, size.Z)
local factor = (scale or 1) * ((authored and native > 0) and authored / native or 1)
if math.abs(factor - 1) > 1e-3 then
m:ScaleTo(m:GetScale() * factor)
end
倍率を決め打ちしないので、読み込みの挙動が変わっても寸法は SceneryDims.luau に合います。
6. Lune で単体テストする(game を読まない層を分ける)
ゲームの規則は純粋な関数にして、Config と Logic に置きます。Roblox と Lune で require の書き方が違うので、script の有無で分けます(src/shared/Logic.luau)。
-- Profile rules as pure functions. The server calls these; Lune tests and the balance
-- simulator call them directly. Mutators return ok:boolean, err:string?, result:any.
local function import(name)
if script then
return require(script.Parent:WaitForChild(name))
end
return require("./" .. name)
end
テストは依存なしの小さな関数です(tests/logic.spec.luau)。
local passed, failed = 0, 0
local function check(name, cond, detail)
if cond then
passed += 1
else
failed += 1
print("FAIL: " .. name .. (detail ~= nil and (" -- " .. tostring(detail)) or ""))
end
end
役に立った例は、サーバー側のズル対策が正直なプレイヤーを削らないことを調べるテストです。クライアントの申告をサーバーが再計算する設計なので、再計算の条件を厳しくすると普通に飛んだ人の記録まで切れます。正直に飛ぶモデルをテストの中に持って、種を変えて回します。
check("honest flights are never cut (beyond a frame)", clipped == 0, string.format("%d of %d, worst %.2f s", clipped, flights, worst))
check("the honest model flies long runs", longest > 40, longest)
実行結果です。
$ lune run tests/logic.spec.luau
[v16] lift replay: honest longest 337.9 s (never cut, worst margin -0.01 s); every pickup 681.6 s, everything 822.8 s, of 3600
[v17] everything + tricks every 3 s lasts 421 s
492 passed, 0 failed
config load ok
雷の区間の作り方を変えたときも、このテストが落ちて、区間の終わりを進めてはいけないと分かりました。
限界もはっきりしています。UI.luau など game を使う層は調べていません。罠の1・2・5・9 はこの外で起きました。古い tests/balance.luau は壊れたままで、整理も追いついていません。
7. PolicyService: 分からない間は閉じる
このゲームのたまごは、Robux で買える Wins で開けられるので、有料ランダムアイテムとして扱い、プレイヤーごとに返る ArePaidRandomItemsRestricted を尊重します。書き方のポイントは、答えが来るまで・取得に失敗したときは閉じることです(src/server/Game.luau)。
session[player] = {
-- (中略)
randomRestricted = nil, -- unknown until PolicyService answers; eggs stay closed meanwhile
}
-- (中略)
task.spawn(function()
local ok, info = pcall(PolicyService.GetPolicyInfoForPlayerAsync, PolicyService, player)
local s = session[player]
if s then
s.randomRestricted = not (ok and info and info.ArePaidRandomItemsRestricted == false) and not Dev.active
markDirty(player)
end
end)
たまごを開ける処理は ~= false で判定します。nil も閉じる側に入ります。
-- eggs cost Wins, which can be bought with Robux: a paid random item. Respect the player's policy.
if s.randomRestricted ~= false then
return false, "restricted"
end
Dev.active は RunService:IsStudio() です。Studio のテストではたまごを開けられるようにしています。制限が付いたプレイヤーでの動作を、実機で確かめた記録はまだありません。
8. Studio を前面化できない環境でテストする
私が PC を使っている間も Claude Code が動きます。10/31 に、前面化してクリックする操作で別のアプリ(Codex)を押しました。それ以降の決まりです。
| やること | 手段 | 前面化 |
|---|---|---|
| 画面を撮る |
PrintWindow(pwshot.ps1) |
しない |
| ログを読む |
%LOCALAPPDATA%\Roblox\logs の最新を grep |
しない |
| F5 でテスト開始 |
PostMessage(postkey.ps1)。途中から効かなくなった |
しない |
| 再生ボタンや F5 を押す |
SetForegroundWindow +クリックやキー(fgclick.ps1・studio_key.ps1) |
する(無操作のときだけ) |
撮影です。PrintWindow はフラグ 2 で呼びます(pwshot.ps1)。
$r = New-Object PW+RECT; [PW]::GetWindowRect($p.MainWindowHandle, [ref]$r) | Out-Null
$w = $r.R - $r.L; $h = $r.B - $r.T
$bmp = New-Object System.Drawing.Bitmap $w, $h
$g = [System.Drawing.Graphics]::FromImage($bmp); $dc = $g.GetHdc()
[PW]::PrintWindow($p.MainWindowHandle, $dc, 2) | Out-Null
前面化は二重に守ります。まず無操作の確認(idle_guard.ps1)。
$l = New-Object Idle+LII; $l.cbSize = 8; [Idle]::GetLastInputInfo([ref]$l) | Out-Null
$idle = [math]::Round(([Environment]::TickCount - $l.dwTime) / 1000)
if ($idle -lt $MinIdle) { "busy ($idle s)"; exit 1 }
次に、クリックの直前に前面のウィンドウが本当に Studio かを確かめます(fgclick.ps1)。
# never click or type into whatever else is in front (the user may be working)
$fgPid = 0; [FG]::GetWindowThreadProcessId([FG]::GetForegroundWindow(), [ref]$fgPid) | Out-Null
if (-not $p -or $fgPid -ne $p.Id) { "skipped: Studio is not in front"; exit 2 }
テスト用の裏口は DevFlags.luau に集めて、Dev.flag は Studio でしか真になりません。公開スクリプトが毎回全部 false に戻します。
def reset_dev_flags():
text = DEVFLAGS.read_text(encoding="utf-8")
text = text.replace("= true", "= false")
DEVFLAGS.write_text(text, encoding="utf-8", newline="\n")
assert "= true" not in DEVFLAGS.read_text(encoding="utf-8")
詰まった所がもう1つ。開いている場所は再読み込みされないので、確認用は別名の rbxlx にして別の Studio で開きます。
9. 絵文字フォントの豆腐(➜ と ☰)
➜ と ☰ はゲームのフォントに無く、空白や豆腐になっていました。メニューのボタンは空でした。文字列として正しいので、型チェックにもテストにも出ません。Studio で画面を見て分かりました。
-- v24: an emoji, not "☰" (the game fonts have no ☰: the button was blank)
Ux.menuIcon = text({ Text = "🧰", Size = UDim2.fromScale(1, 1), TextScaled = true, Parent = Ux.menuBtn })
矢印は全部 ➡️ に置き換えました(Ux.loopArrows など)。いま src の ➜ ☰ はコメントだけに残っています。
rg "[➜☰]" src --glob "*.luau"
リポジトリには、この検査はまだ入っていません。入れるなら compile_check.luau と同じ所に、禁止文字の一覧を足す形がよさそうです。
まとめ
型チェックとテストを通すだけでは、1・2・5・9 が残りました。公開の前に機械で止められるようになったのは、まだ1だけです。ほかは Studio の実行ログと目視に頼っています。
16歳未満のプレイヤーに届けるには、本人確認・2段階認証・1,000 Robux の手数料(または Plus / Premium 2か月)・250人の評価(私の管理画面の表示)が要ります。条件と作り替えの経緯は note のほうにあります。
- note: https://note.com/views_of_life/n/naa05fee49e48
- ゲーム: https://www.roblox.com/games/106965167223926
- サイトのゲームページ: https://yuichi916.github.io/balloon.html
- 条件の公式ページ: https://create.roblox.com/docs/production/publishing/kids-and-select
遊んでいる様子は動画で見られます: https://www.youtube.com/watch?v=a5CWeVJ8Xbw
公開するまでの流れは長めの動画にまとめました: https://www.youtube.com/watch?v=7mdH3FXKc1M
記事中のコード片は自由に使ってください(MIT、名義 ViewsEngineer)。
