# Porting STATE PROPERTY to Roblox This is the engineering companion to the game. It explains the split between rules and engine adapters, exactly what travels over the wire in the Roblox version, and what to change when the concept becomes a real place. **Status, plainly.** The shared Luau rules are parity-tested against the JavaScript rules under the standalone `luau` CLI (26 scenarios, 193 fields, all passing). The Roblox server/client/world/HUD files are complete and parse, but there is no Roblox Studio in the environment this was built in, so they have **not** been run in a place. Treat `roblox/` as a tested port kit with a short first-run checklist, not a shipped experience. The checklist is at the end of this document. --- ## 1. The one architectural rule **Rules never import an engine.** ``` ┌─────────────────────────────┐ │ src/shared/*.js │ │ config · pond · fish · │ │ rng · run │ │ pure JS: numbers in/out │ └────────────┬────────────────┘ │ imported by ┌────────────▼────────────────┐ │ src/web/*.js │ │ three.js + DOM adapters │ │ camera, rigs, HUD, audio │ └─────────────────────────────┘ ┌─────────────────────────────┐ │ roblox/src/shared/*.luau │ │ Config · Rng · Pond · │ │ Fish · Run │ │ same functions, same │ │ constants, same events │ └──────┬──────────────┬───────┘ │ │ ┌────────▼──────┐ ┌─────▼────────────┐ │ Roblox server │ │ Roblox client │ │ GameService │ │ GameClient │ │ WorldBuilder │ │ Hud · CameraRig │ │ R6Rig │ │ input → remotes │ └───────────────┘ └──────────────────┘ ``` Both adapters do the same three things: 1. **Feed input in:** `setMove(x, z)`, `setTiptoe(bool)`, `setExtract(bool)`, `pressCast()`, `releaseCast()`, `pressBoo()`. 2. **Advance time:** `step(state, 1/60)` in a fixed-step loop. 3. **Read state out:** positions, detection, warden state, cast reel, spots, caught fish, events. Nothing about a renderer leaks into a rule, and nothing about a rule leaks into a renderer. --- ## 2. File map | Web | Roblox | Responsibility | | --- | --- | --- | | `src/shared/config.js` | `roblox/src/shared/Config.luau` | all tuning, in studs/seconds | | `src/shared/rng.js` | `Rng.luau` | LCG + pure hash; no `math.random` in rules | | `src/shared/pond.js` | `Pond.luau` | spots, hide zones, truck/shed/sign, patrol, collision | | `src/shared/fish.js` | `Fish.luau` | 12 fish, names, weights, quips, deterministic pick | | `src/shared/run.js` | `Run.luau` | the entire simulation and score | | `src/web/engine.js` | `WorldBuilder` + `Lighting` | renderer vs. world lighting | | `src/web/world.js` | `roblox/src/server/WorldBuilder.luau` | pond, props, markers | | `src/web/rig.js` | `R6Rig.luau` (NPC) + Roblox avatar (player) | R6 characters | | `src/web/hud.js` | `roblox/src/client/Hud.luau` + `HudTheme.luau` | HUD, notebook, end screen | | `src/web/input.js` | `GameClient.luau` | keyboard/touch → world-space input | | `src/web/audio.js` | — | procedural web audio; the port has no audio assets | | `src/web/main.js` | `GameService.luau` + `GameClient.luau` | loop, events, camera | | `tools/luau-parity.mjs` | — | runs both rule sets and diffs the results | The web game is the reference renderer. If a mechanic looks different between the two builds, the rules are right and the adapter is wrong. --- ## 3. The shared contract ### 3.1 State `Run.createRun({seed})` / `Run.new(seed)` returns one table/object: | Field | Meaning | | --- | --- | | `seed` | deterministic seed; drives the fish table and green-band placement | | `time`, `timeLeft` | run clock; `timeLeft` starts at 95 | | `player` | `x, z, fx, fz, speed, tiptoe, hidden, moving` | | `input` | latest normalized move vector (set by `setMove`) | | `warden` | `x, z, fx, fz, state, targetIndex, detection, memory, searchTimer, stunTimer, lastSeenX/Z, visible, scanTimer, scanPhase` | | `cast` | `active, reel, dir, held, spotId, greenStart, greenEnd` | | `spots[]` | `id, label, readyIn, cycle` | | `caught[]`, `confiscated[]` | fish records (`name, weight, quip, spot, at`) | | `strikes`, `stuns`, `boos`, `booCooldown` | consequences and cooldowns | | `grace`, `wantExtract`, `extractHold` | respawn protection, extraction hold | | `over`, `won`, `reason`, `score`, `rank` | result | | `events[]` | one-shot events, drained by the adapter | `rank` is one of `owner | legal | trespasser | evicted | locked_up`; the display copy lives in `RANKS` and travels with the rules. ### 3.2 Functions | JS | Luau | Notes | | --- | --- | --- | | `createRun({seed})` | `Run.new(seed)` (+ `Run.create`) | fresh state | | `step(state, dt)` | `Run.step(state, dt)` | clamps `dt` to 0.05 internally | | `setMove(state, x, z)` | `Run.setMove` | x/z in studs, already camera-relative | | `setTiptoe(state, bool)` | `Run.setTiptoe` | held | | `setExtract(state, bool)` | `Run.setExtract` | held; extraction is 1.3 s | | `pressCast` / `releaseCast` | same | press roots the player; release resolves the green band | | `pressBoo` | same | 9 s cooldown, 9.5 stud range, 2.4 s stun | | `drainEvents(state)` | `Run.drainEvents` | clears and returns the event queue | | `fishCarried`, `scoreOf`, `rankOf` | same | score/rank are also on state | | `CONFIG`, `WARDEN_STATE`, `RANK`, `QUOTES`, `RANKS`, `SPOTS`, `TRUCK`, `POND` | same | travel with the rules | ### 3.3 Events The event `type` strings are part of the contract: `cast_start`, `cast_success`, `cast_escape`, `cast_timeout`, `reel_bounce`, `chase_start`, `search_start`, `caught`, `boo`, `boo_stun`, `boo_wasted`, `stun_end`, `patrol_resume`, `scan_start`, `scan_end`, `spot_ready`, `extract_start`, `extracted`, `win`, `lose`. Payloads include what the HUD needs (`fish`, `weight`, `quip`, `total`, `spotId`, `lost`, `strikes`, `quote`, …). The web HUD and the Roblox HUD consume the same fields. `quote` is also used by the Roblox server to set the Warden's BillboardGui speech bubble. ### 3.4 Units, axes, seeds, indices - **1 unit = 1 Roblox stud**, everywhere. The web's three.js units are studs. - **Y is decoration.** The simulation is 2D over `(x, z)`; y is only used to place parts/models. No gravity, no slopes, no jump. - **+Z is "toward the dock"** in the authored layout: pond center `(0, 0)`, spawn `(0, -25)`, dock `(0, 23.6)`, truck `(-17, -30)`, shed `(-30, -22)`, sign `(16, -19.5)`. Both engines use these exact numbers. - **Seeds.** `Run(seed)` uses the same LCG (`state = state*1664525 + 1013904223 mod 2^32`) and the same hash for fish. Same seed, same fish, same green band. - **Indices.** JS arrays are 0-based. Luau arrays are 1-based. The only places this matters are `spots[i].index` (1-based in Luau), `warden.targetIndex` (1-based in Luau), and the fish hash: Luau calls `fishFor(seed, record.index - 1, cycle)` so the hash sees the same 0-based index as JS. Do not "fix" this; it is asserted by the parity harness. --- ## 4. Roblox project setup ### 4.1 Rojo `roblox/default.project.json` maps: ``` ReplicatedStorage/Shared <- roblox/src/shared ServerScriptService/StatePropertyServer <- roblox/src/server StarterPlayer/StarterPlayerScripts/StatePropertyClient <- roblox/src/client ``` ```bash # from the repo root rojo serve roblox/default.project.json # or build a place file rojo build roblox/default.project.json -o StateProperty.rbxlx ``` Open the place, connect Rojo, press Play. `Main.server.luau` is the server entry; `GameClient.client.luau` is the LocalScript. ### 4.2 Game settings 1. **Avatar → R6.** The web game and the Warden are classic R6 silhouettes; R15 works but changes the look. 2. **Max players = 1.** The shared state owns *one* player and *one* Warden. A second player is kicked with a friendly message by `GameService`. 3. **StarterPlayer:** the client disables the default controls and camera; no extra toggles are required. 4. **Streaming:** the world is ~300 generated parts; streaming is fine but not required. 5. **API services:** not needed. No DataStores, no HTTP, no uploads. ### 4.3 What the server builds `WorldBuilder.build()` creates the ground, bank, glass pond, dock, sign with `SurfaceGui` text, rusty pickup, shed, fence, pines, string lights, burn barrel (with a legacy `Fire`), buoys, fishing-spot rings/beams, hide-zone rings, and a transparent `SpawnLocation`. It sets `Lighting` to a dusk grade with an `Atmosphere` and a `ColorCorrectionEffect`. All props are `Anchored = true` and `CanCollide = false` except the ground, so the pivoted actors never fight physics. --- ## 5. Server authority `GameService.luau` is the only owner of a run. ### 5.1 Lifecycle ``` PlayerAdded → setupCharacter (WalkSpeed=0, JumpPower=0, AutoRotate=false, root anchored) → wait for Ready (CLOCK IN) → startRun: Run.new(seed), spawn Warden, pivot, Start:FireClient, snapshot → Heartbeat: fixed 60 Hz steps, pivot, 20 Hz snapshot, flush events → state.over: final snapshot + events, running=false → retry: startRun again PlayerRemoving → destroy Warden, clear state ``` ### 5.2 Character control The character is a statue driven by the simulation: ```lua character:PivotTo( CFrame.new(state.player.x, 3, state.player.z) * CFrame.Angles(0, math.atan2(-state.player.fx, -state.player.fz), 0) ) humanoid.WalkSpeed = 0 humanoid.JumpPower = 0 humanoid.AutoRotate = false rootPart.Anchored = true ``` This is the deliberate shortcut that makes the port match the web game: the rules decide the position, not Roblox physics. The cost is no character animation matching velocity; see §10. ### 5.3 Snapshots `Snapshot:FireClient(player, table)` at 20 Hz: ```lua { seed, time, timeLeft, px, pz, pfx, pfz, wx, wz, wfx, wfz, wstate, detection, fish, strikes, stuns, score, rank, over, won, reason, cast = { active, reel, greenStart, greenEnd }, spots = { { id, label, readyIn }, ... }, caught = { { name, weight, quip, spot, at }, ... }, confiscated = { ... }, hidden, hiddenLabel, extractHold, } ``` The client uses `caught`/`confiscated` for the notebook and end screen, `spots` for prompts, `wstate`/`detection` for the HUD and camera, and `cast` for the reel meter. ### 5.4 Input packets The client sends at 20 Hz: ```lua { x = worldX, z = worldZ, tiptoe = bool, extract = bool } ``` and on button edges: ```lua { action = "castDown" } | { action = "castUp" } | { action = "boo" } | { action = "retry" } ``` `x/z` are already rotated into the camera's frame on the client. The server clamps them and calls `Run.setMove`. **Do not trust a client to send a position or a fish count.** The port kit's surface is small on purpose. --- ## 6. Client ### 6.1 Input The default PlayerModule controls are disabled. Each frame the client reads `W/A/S/D` and the touch joystick, then transforms the input: ```lua local inward = -Vector3.new(px, 0, pz).Unit -- from player toward pond center local worldX = inward.X * mz + inward.Z * mx local worldZ = inward.Z * mz - inward.X * mx ``` where `mx` is screen-left (A = +1) and `mz` is screen-up (W = +1). This is the same math as `main.js`, and the same radial frame the camera uses. Holding `W` always walks toward the pond; `A/D` circle it. `E`/FISH is a hold: `castDown` on press, `castUp` on release, and the held packet carries `extract = true`. `B` sends `boo`. `Tab` toggles the notebook locally. `Shift` is read as a held tiptoe. ### 6.2 Camera `CameraRig.luau` mirrors the web camera. The camera sits on the far side of the player from the pond: ```lua local pos = Vector3.new(snap.px, 0, snap.pz) local inward = -pos.Unit local camPos = pos - inward * 24 + Vector3.new(0, 30, 0) local lookAt = pos + inward * 7 + Vector3.new(0, 2, 0) camera.CFrame = smooth:Lerp(CFrame.lookAt(camPos, lookAt), 1 - math.exp(-5 * dt)) ``` Chase pulls back 2 studs and up 2; over pulls up 4; touch widens the distance. The camera is `Scriptable`, smoothed on `RenderStepped`. ### 6.3 HUD `Hud.luau` builds a `ScreenGui` with the same hierarchy as the DOM HUD: brand, clock, fish/wanted, suspicion fill, prompt, cast meter, flash, toasts, intro/CLOCK IN, notebook (`SCRIB DOON`), and the end card. `HudTheme.luau` carries the palette and constructors. The Warden's speech is a `BillboardGui` on his head, set by the server from `event.quote`. Touch: a joystick frame, FISH (hold), BOO, TIPTOE, BOOK. The joystick writes into the same `touchMove` vector the keyboard path uses. --- ## 7. Rendering: three.js → Roblox | Web | Roblox | Notes | | --- | --- | --- | | `WebGLRenderer` + ACES | `Lighting` + `Atmosphere` + ColorCorrection | no post-processing | | shader water disc | `Part` with `Shape = Cylinder`, `Material = Glass`, teal | the animated shader has no direct Part equivalent | | instanced reeds | ~18 thin `Part`s per cluster | ~90 parts | | spot glow ring + beam | flat Neon cylinder + vertical Neon cylinder | honest, readable | | hide-zone ring | flat translucent Neon cylinder | | | sign CanvasTexture | `SurfaceGui` + `TextLabel` on both faces | fonts differ | | truck/shed boxes | welded/anchor-free `Part` models | | | string lights | Neon bulbs + one `PointLight` | | | fireflies | omitted (or a `ParticleEmitter` later) | | | R6 rig groups | Roblox avatar (player) + `R6Rig` model (Warden) | | | flashlight cone mesh | `SpotLight` on the lens + emissive lens | cone visual omitted | | contact shadows | Roblox shadows | | | DOM HUD | `ScreenGui` | see §6.3 | What is lost: the custom water shader, bloom-ish glow sprites, the exact typography (the web uses Barlow Condensed / Space Grotesk), fireflies, and the procedural audio. What is kept: every gameplay position, every prop that matters, the palette, the silhouette language. --- ## 8. World/layout as data Both world builders consume the same table. To move a fishing spot, change it in `pond.js` and `Pond.luau` (the parity harness will fail if you forget one), then update the art anchors if needed. The categories: - `SPOTS` — five rings: dock, west/east bank, west/east point. - `HIDE_ZONES` — two reed beds, the sign, the back of the shed. The visual reeds for the first two are in `REED_CLUSTERS`; the sign and shed are props. - `POND` — center `(0,0)`, radius 22, plus the walkable `DOCK` rect. - `TRUCK`, `SHED`, `SIGN` — the truck is both an obstacle and the extraction point (`extractRadius` 6.5). - `WARDEN_PATH` — eight waypoints on a 30-stud ring, starting at the east point. - `BOUNDS` — the rectangular play area. The Warden's scan stop is in `Run.luau`, not the world: every 3.2 s he plants for 1.6 s and turns toward `(0,0)`. That is what makes the bank risky. The flashlight/cone visuals follow his facing automatically. --- ## 9. The parity harness `tools/luau-parity.mjs`: 1. Copies `Config.luau`, `Rng.luau`, `Pond.luau`, `Fish.luau`, `Run.luau` into `.parity/`. 2. Rewrites `require(script.Parent.X)` to `require("./X")` (the CLI style). 3. Runs `Parity.luau` under `luau` with 26 scenarios and prints `TAG|fields`. 4. Runs the same scenarios against `src/shared/*.js` and diffs every field, with a 1e-6 tolerance on numbers and exact matches on strings. 5. Deletes `.parity/` and exits non-zero on any mismatch. Scenarios: movement/collision, a perfect cast, a missed cast, detection, chase, catch, boo stun, jail, extraction, timeout, the 12 fish, constants, and `spotIndex`. It also caught a real JS bug (the undefined SEARCH timer) and a 1-based/0-based fish-index shift during initial development. If you add a rule: add it to both `run.js` and `Run.luau`, add a scenario to the harness, and keep the output field count identical. --- ## 10. Production hardening / known gaps **Character animation.** The server pivots; the client sees no walk cycle. In a real port, keep server authority but send the intended velocity, use `Humanoid:Move` on the client for prediction and an animation controller (`R6` walk/idle/tiptoe), then reconcile to the server position. Or accept the statue look as a stylistic choice. **Client prediction.** Snapshot interpolation is not prediction. At 20 Hz the camera is smooth but the character can lag. A production port would run `Run.luau` on the client with the same seed for prediction and correct on the server snapshot. **Multiplayer.** One run = one player = one Warden. For a public place, either (a) one server per player with `ReservedServer` / matchmaking, (b) per-player instances on a shared server (teleport each player to a private arena region), or (c) redesign to a shared round with multiple anglers, one Warden, and a server-side roster of per-player states. The shared rules would need a player list in every function; do this only if co-op is the goal. **Audio.** The web build synthesises everything. Roblox needs uploaded sound assets and IDs; none were uploaded for this concept. The `GameClient` has hooks where `Hud.event(event)` is called — play sounds there. **Persistence.** None. No DataStores, no leaderstats, no saved notebooks. The notebook is per-run by design. **Typo/accessibility.** The Roblox HUD uses `Antique`/`GothamMedium` because custom fonts require uploads. The web fonts are OFL and can be uploaded and referenced with `FontFace` if wanted. Color contrast is tuned for the web; check it on a TV/mobile profile in Studio. **First-run checklist in Studio** 1. `rojo serve` (or build and open), Play. 2. The intro appears; CLOCK IN sends `Ready`; the HUD appears; the Warden spawns at the east point. 3. WASD walks toward the pond (the camera is radial); the character is anchored and pivots. 4. Hold E at the dock ring; the reel panel appears; release in the green; a fish is logged; the notebook shows it. 5. Walk into the flashlight cone; the suspicion bar fills; the chase starts; the speech bubble appears; get caught; a strike and the teleport happen. 6. Press B within 9.5 studs; the Warden freezes; the star/dizzy look is a TODO (the visual stun is the frozen model + stopped cone). 7. Catch six fish, go to the truck, hold E, see the rank card; RETRY starts a new seed. 8. Check the Output window for errors; the shared modules have no Roblox services, so any error there is a real port bug. If step 2 shows a kicked player, check `Players.MaxPlayers = 1`. --- ## 11. Why this is reusable The useful part is the boundary, not the pond. The same pattern works for any meme or minigame: 1. Put the verbs and the consequences in a pure module with a fixed step. 2. Give the module a seed, a state table, and an event queue. 3. Write one adapter per engine that only feeds input and draws state. 4. Diff the two rule sets with a scripted scenario, field by field. 5. Port the art separately. The art can be wrong; the rules cannot. That is why the Roblox version is not a rewrite. It is the same argument in a different font.