1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Luau・Rojo・Open Cloud・Lune で Roblox ゲームを公開するまでに踏んだ9つの罠(コード付き)

1
Last updated at Posted at 2026-11-05

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 のほうにあります。

遊んでいる様子は動画で見られます: https://www.youtube.com/watch?v=a5CWeVJ8Xbw

公開するまでの流れは長めの動画にまとめました: https://www.youtube.com/watch?v=7mdH3FXKc1M

記事中のコード片は自由に使ってください(MIT、名義 ViewsEngineer)。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?