# Porting TIP TOEIN' to Roblox Studio This is the short version for someone opening the place for the first time. The browser game and the Roblox game are the same game because they share one rulebook. The rulebook is `src/shared/sim.js`. The Roblox mirror is `roblox/src/shared/Sim.luau`. The parity harness runs both on the exact same input frames and compares the results — it currently reports **0.000** float delta across five scenarios. That is the whole porting strategy: **no game rule is allowed to live in the three.js code**. If you change a speed, a vision angle, a noise rate, the score or a guard route, you change it in the shared data and both runtimes follow. --- ## 1. The split ``` src/shared/ pure JavaScript, runs in Node tests and the browser config.js every tunable number level.js the hall: obstacles, hide zones, straw, routes sim.js stepSim(state, input, dt) -> events[] src/web/ three.js + DOM adapters (never decides a rule) world.js scene, props, lights, vision cones, beacons rig.js original R6-proportioned box characters hud.js input.js audio.js main.js roblox/src/shared/ Luau mirrors of the pure layer Config.luau Level.luau Sim.luau R6Rig.luau roblox/src/server/ Main.server.luau builds the hall, runs Sim.step at 60 Hz, poses rigs roblox/src/client/ GameClient.client.luau camera, HUD, keyboard/touch input, end screen roblox/default.project.json Rojo tree roblox/tests/parity.luau headless Luau scenario runner tools/luau-parity.mjs compares browser vs Luau snapshots ``` `Sim.luau` has no Roblox globals in it. That is deliberate: the same module can be required by the server and by the standalone `luau` CLI for parity. --- ## 2. The contract Everything in the shared layer is plain tables and numbers. ### Units - **1 unit = 1 Roblox stud.** - Y is up. - X is east, Z is south. North is −Z. - The hall is 76 × 53 studs of playable floor inside walls that are 80 × 56. - A goblin is about 6 studs tall (R6-ish). ### Input ```js { x, z, sprint, use } // x/z are a world-space direction, -1..1 ``` The browser turns WASD into this. The Roblox client turns `UserInputService`/the touch D-pad into this and fires the `Input` remote at 30 Hz. The server clamps it and hands it to `Sim.step`. ### State `createState()` returns the whole run: `phase`, `time`, `noise`, `gold`, `hearts`, `alerts`, `player`, `straw`, `guards`, `cat`, `spin`, `stats`. The renderer reads it. It never writes it (except the QA hook). ### Step ```js stepSim(state, input, dt) -> events[] ``` - `dt` is clamped to 0.05 s. - The browser and the Roblox server both tick at **1/60**. - Events are `{ type, ... }`: `straw`, `spinStart`, `gold`, `guardWake`, `alert`, `calm`, `cat`, `caught`, `drop`, `needStraw`, `needGold`, `win`, `lose`. Both HUDs are driven by these, not by guessing from state diffs. ### Determinism There is no RNG in the simulation. Guard waits are fixed. `Math` and Luau `math` both use doubles; the parity harness tolerates 0.06 studs on positions and 0.03 on angles, and currently reports 0.000. If you add randomness, put a seeded integer PRNG in both files. --- ## 3. Level data → Roblox parts Every row in `level.js` has a direct Roblox construction in `Main.server.luau`. The coordinates are the same; no conversion. | Data | Browser | Roblox | | --- | --- | --- | | `bounds` | sim collision + nginx-safe floor | wall/parapet parts; `Sim.canStand` | | `spawn` | goblin start | goblin root CFrame | | `obstacles[]` | collision, line-of-sight blockers, meshes | `Part` with `Size = (w,h,d)`, `CFrame = (x, h/2, z)`, `CanCollide = solid` | | `hides[]` | invisibility rectangles | faint gold floor parts + `hideZoneAt` | | `straw[]` | auto-pickup + beacon | bale model + BillboardGui | | `guards[]` | `patrol`/`alert` sim + cones | R6 rig + fan of cone blades + waypoint loop | | `wheel`, `cat`, `exit` | props + interaction ranges | models + the same constants | The banquet table is `solid: false` on purpose: you can walk under it. In the Roblox build its legs are `CanCollide = false` too, so the goblin can duck under. The hide zone is a region check, not physics. Hide zones are the one place where the browser is more generous: the gold outlines are drawn so the player can learn the map. In Roblox they are faint parts. Keep them faint; they are a teaching aid, not a quest marker. --- ## 4. What the Roblox runtime does `Main.server.luau`: 1. `Players.CharacterAutoLoads = false`. The concept has no default avatar running around; the goblin is a server-owned R6 rig. 2. Builds the hall from `Level`. Pylons are cylinders, barrels are cylinders, the bed has a canopy, the wheel is a disc on a post. 3. Builds a custom goblin, a cat, and one guard per route. 4. Creates five remotes in `ReplicatedStorage.TipToeRemotes`: `Input`, `Snapshot`, `GameEvent`, `Restart`, `Started`. 5. On the first player, starts a run and fires `Started`. 6. `RunService.Heartbeat` accumulates to 1/60 and calls `Sim.step`. Events go to the client immediately; snapshots go at 10 Hz. 7. Poses the goblin/guards/cat from the state every frame (anchored parts, direct CFrame writes — see §9 for the production upgrade). 8. On `won`/`lost`, fires `GameEvent {type="gameover"}` and resets after 6 s. `GameClient.client.luau`: - Sets a scriptable camera: a whole-hall diorama on desktop-equivalent screens, a follow camera otherwise. It uses the same offsets as the browser. - Sends input at 30 Hz. - Draws the HUD (objective, timer, gold, hearts, noise, prompt, hidden badge, toasts, end card, touch D-pad + RUN/USE). - Rebuilds the end card from the `gameover` event. The client does **not** predict. That is fine for a single-player stealth game over a LAN; for production, see §10. --- ## 5. Open it Requires [Rojo](https://rojo.space/) 7+. ```sh cd roblox rojo serve # then connect from the Rojo plugin in Studio # or build a place file: rojo build -o TipToe.rbxlx ``` The tree maps: ``` ReplicatedStorage/TipToeShared/ Config, Level, Sim, R6Rig ServerScriptService/TipToeServer/ Main (Script) StarterPlayer/StarterPlayerScripts/TipToeClient/ GameClient (LocalScript) ``` The shared modules are named exactly as the browser files; `Sim.luau` requires `Config` and `Level` from `script.Parent` in Studio, and from `./Config`/`./Level` when run by the parity CLI. That conditional is the only "environment" code in the shared layer. --- ## 6. Parity harness ```sh node tools/luau-parity.mjs ``` It generates `roblox/tests/_inputs.luau` from `tools/scenarios.mjs`, runs `roblox/tests/parity.luau` with the standalone `luau` CLI, runs the same scenarios through `src/shared/sim.js`, and compares this tuple per scenario: ``` phase · time · noise · gold · hearts · alerts · player x,z,angle,carrying,hidden · each guard: active,x,z,angle,suspicion,mode · cat cooldown,clap · straw taken · spin active/progress · score ``` Scenarios: | Name | What it covers | | --- | --- | | `patrol` | movement, walls, noise decay, guard patrol, timer | | `sprint` | sprint speed + the squeak meter | | `spin` | pickup state, spin lock, gold, waking the Chamberlain | | `cat` | cat clap, noise spike, guard suspicion | | `catch` | detection fill, catch, heart loss, respawn, drop | Expected output: ``` patrol PASS max |Δ| 0.000 playing, gold 0, hearts 3 sprint PASS max |Δ| 0.000 playing, gold 0, hearts 3 spin PASS max |Δ| 0.000 playing, gold 1, hearts 3 cat PASS max |Δ| 0.000 playing, gold 0, hearts 3 catch PASS max |Δ| 0.000 playing, gold 0, hearts 2 PARITY OK — worst float delta 0.0000 ``` If you edit one side, run this before touching the other. A divergence is a rule that leaked into an adapter. --- ## 7. Using real Roblox avatars The browser characters are original boxes, not Roblox assets. In Studio you can use the player's real avatar instead: 1. Set `Players.CharacterAutoLoads = true` (currently `false`). 2. On `CharacterAdded`, keep the character but disable its default movement: `humanoid.WalkSpeed = 0`, or use `Humanoid:ChangeState(Enum.HumanoidStateType.Physics)` and move the `HumanoidRootPart` from the sim each tick. 3. Set the camera subject to the character's `Humanoid` (or keep the scriptable camera and aim it at the character). 4. Replace `R6Rig.poseGoblin` with the Animator: load the walk animation and set its speed from `state.player.speed`; play the "carry" pose when `player.carrying`. 5. The sim still owns the position: do not let physics and the sim both move the same character, or `canStand` and the server position will disagree. The server currently drives a dedicated goblin because that keeps the browser and Roblox screenshots comparable. Swapping in avatars is a presentation change, not a rules change. --- ## 8. Multiplayer and scale The Roblox build is **one active run per server**. The first player controls the goblin; anyone else gets a "server already has a goblin" card. That is honest for a concept, and it keeps the remotes simple. For production: - **Reserved servers** or a lobby teleport: one player per place means one run per player and no interference. - Or **per-player copies** of the hall in separate regions of the map, with each run's guard rigs and state isolated. `Sim.create()` is already a self-contained state object, so this is mostly plumbing. - Move `stepSim` to the server only (it already is). Never trust the client input beyond clamping; the client currently sends a direction and two booleans, which is the right shape. - Snapshots are 10 Hz and tiny. If you add players, send only what changed, or send a compact binary pack. - Anti-cheat: the server owns movement and detection, so the only thing a client can do is lie about direction/sprint/use. Clamp direction length and rate-limit `use`; otherwise the sim is authoritative. --- ## 9. Performance notes The port kit intentionally favours readability over the fastest possible rig: - The goblin and each guard are ~20 anchored parts positioned every frame. A production pass would use a single R6 model with `Motor6D` joints and an `Animator`, or a skinned mesh, and animate only the joints. - The vision cones are a 26-segment triangle fan in the browser and seven overlapping neon blades in Roblox. Both are cheap. If you need to match the exact cone silhouette, build a single `WedgePart`-style mesh or an `EditableMesh` sector instead. - Eleven point lights in the browser (six torches, chandelier, fill, terrace fill, braziers, exit). In Roblox only the torches + exit + wheel glow are real lights; the rest is baked into the ambient look. Keep it that way on mobile. - `StreamingEnabled` is off in the Rojo project. The hall is one room; turn it on only if you add a larger map. --- ## 10. Porting checklist When you change the game: 1. Change `src/shared/config.js` and `roblox/src/shared/Config.luau` together. 2. Change `src/shared/level.js` and `roblox/src/shared/Level.luau` together. 3. If a rule changes, change `sim.js` and `Sim.luau` together. 4. Run `node --test tests/*.test.mjs`. 5. Run `node tools/luau-parity.mjs`. It must print `PARITY OK`. 6. Run the browser game at 1280×720 and 390×844. 7. In Studio, run `Main.server.luau` with one player; check spawn, straw, spin, cone colours, catch/respawn, and the window at 3 gold. If you only change visuals (`src/web/` or `R6Rig.luau`), parity is irrelevant — but do not let a visual change quietly become a rule. The browser should not be the only place that knows a guard's range. --- ## 11. Known gaps (on purpose) - **One player per server.** Documented above; not a hidden bug. - **No client prediction.** Movement is server-driven at 30 Hz input; a bad connection will feel like input delay, not rollback. For a single-player stealth game over a decent connection this is fine. - **The browser goblin is not the player's avatar.** Original R6-proportioned rig; Studio swap instructions above. - **Roblox cones are seven blades**, not the exact sector mesh. It reads the same; it is not pixel-identical. - **No mobile Roblox thumbstick tuning.** A D-pad + buttons are wired for touch; a proper dynamic thumbstick is a small follow-up. - **The queen is decoration.** She never wakes; the noise meter is the only pressure. If you want a second fail state, add a `queenWake` check in both sims at noise 1.0 for 2 s — and add a parity scenario for it. --- ## 12. IP notes - No Roblox asset IDs, meshes, sounds or characters are used. The browser rigs are original boxes; the Roblox rigs are original parts. - The Jordans in the game are **generic high-top sneakers** with a gold star, not a Nike logo or silhouette. The meme's "Jordans" is referenced in copy, not reproduced as a trademark. - The meme itself is an internet trend, not a licensed property. The game uses the *idea* (a goblin tip-toeing in loud sneakers) and its own art. - Fonts are SIL OFL; the licences are in `assets/fonts/`. - three.js is MIT; the licence is in `vendor/THREE-LICENSE.txt`.