Scale and maps
Strategy and tycoon games often want the player standing over a tiny country like a giant. The trick: keep the character life size and shrink the world (for example to 1/4).
7 min read
- A giant player in a miniature world
- Map reorganisation breaks things silently
- CollectionService and StreamingEnabled
- Part.Size is not world extent
- Chunked endless maps
- Checklist
A giant player in a miniature world
Strategy and tycoon games often want the player standing over a tiny country like a giant. The trick: keep the character life size and shrink the world (for example to 1/4).
- Author everything at life size, then scale templates. Builders, cell sizes and
character-sized props stay in familiar numbers. A one-shot scaler runs
Model:ScaleTo(S)on every template and marks each with aWorldScaleattribute so a second run skips it (idempotent, safe to re-run after every asset rebuild). - One
Scalemodule ownsS, studs per cell, chunk size, and helpers likeScale.d(x)=x * S. Game code writes distances aslifeSize * S. Viewer-space numbers (the character, camera minimum zoom, GUI pixels) are not scaled. - What
ScaleTohandles (measured): part sizes, joints, attachments, FileMesh scale, particle Size/Speed/Acceleration, beams, trails, fire/smoke, Texture StudsPerTile, billboard sizes/offsets/MaxDistance, SurfaceGui PixelsPerStud, sound rolloff, prompt distances,Humanoid.HipHeight. - What it misses, fix by hand: light
Range,Beam.TextureLength,SelectionBox.LineThickness, BillboardGuiExtentsOffset, any CFrame or stud-valued attributes your code stores, andMaterialVariant.StudsPerTile(variants live in MaterialService, not the model). Scale the variant too, or studs stay life-size on tiny parts. - TrussParts can't go below 2 studs per axis.
ScaleTo(0.25)left 59 ladders as 2x2x2 see-through cubes. Build ladders as plain Parts. ScaleTois absolute. CallingScaleTo(1)on a clone to "reset" it blows a 1/4 model up to life size. Use a relative helper:m:ScaleTo(m:GetScale() * k).- Pooled shell models may report
GetScale() == 1. Read theWorldScaleattribute as the fallback when animating Transform offsets. - Z-fight gaps stay in world studs. Depth precision doesn't shrink with the world.
- Sound from a far camera: the giant's camera sits relatively farther away, so multiply rolloff by a small "ear" factor or the world goes silent.
Map reorganisation breaks things silently
When a level designer moves map content into folders, two things die with no error:
- Top-level discovery scans.
for _, c in workspace:GetChildren()looking forStage1..Stage20found nothing once they moved intoWorkspace.Stages. Six services found zero checkpoints, hazards and teleports. "Found nothing" looks exactly like "there is nothing". workspace:WaitForChild("Container")yields forever once the container moves, taking every line after it with it. Studio prints only a soft "Infinite yield possible" line.
Fix (see snippets/Discovery.luau):
- One shared discovery module that walks down from Workspace through Folders only (cheap, ~100 visits, never wanders into 50k mesh parts).
- Match containers by what they hold (a tag, an attribute, a child class), not by a name that churns.
- Always
warnwhen a lookup finds nothing. - Never
WaitForChilda map container without a timeout. - When a search for something the developer says exists comes back empty, widen the filter
and re-run before concluding it's absent. Authored props are often bare Parts (a
Ballparented to another Part), not tidy Models. If a result is too big to read, narrow the query; skimming 600 lines is not looking.
CollectionService and StreamingEnabled
CollectionService:GetInstanceAddedSignal(tag) does not fire for an instance that streams
in already tagged. That is how everything outside the spawn area arrives. Measured: walking
into a fresh stretch streamed in hundreds of tagged parts and the handler fired zero times.
Anything that builds a cache from GetTagged at startup and maintains it from the signal
freezes at whatever was loaded on join. The bug is invisible: a part that doesn't react looks
like a part nobody wired up.
Fix (see snippets/TagCache.luau):
- Rebuild tag caches on a timer (~1 s) guarded by a cheap change check (the count).
- Keep
GetInstanceRemovedSignalfor cleanup; streaming out does fire it. - Pools:
RemoveTagon release andAddTagon acquire, so per-part cached state (a rest CFrame, say) gets cleared and re-read. - Testing a pooled object: verify by world position and re-find the occupant each sample; a pooled model is not a stable identity.
- Testing a "pressed when stepped on" part: record the rest pose of a target far from the player before stepping on it. Sampling the one under their feet records the pressed pose as the baseline and reports zero travel.
Related StreamingEnabled facts:
workspace.StreamingEnabledis edit-time only. A running script setting it gets "lacking capability" and the whole bootstrap dies at that line. Set it in edit mode; at runtime onlywarnif it's off.PersistentPerPlayermodels plus a large Y lift per player slot is a cheap way to keep one player's arena from streaming to others.
Part.Size is not world extent
Geometry exported from Blender often arrives rotated. A bar authored 174 x 3 x 2.5 stood
174 studs tall in the world. Reading Size.X as "width" sent a hazard along the wrong axis.
-- world-axis extent of a rotated box part (snippets/WorldExtent.luau)
local cf, s = part.CFrame, part.Size
local ext = Vector3.new(
math.abs(cf.RightVector.X) * s.X + math.abs(cf.UpVector.X) * s.Y + math.abs(cf.LookVector.X) * s.Z,
math.abs(cf.RightVector.Y) * s.X + math.abs(cf.UpVector.Y) * s.Y + math.abs(cf.LookVector.Y) * s.Z,
math.abs(cf.RightVector.Z) * s.X + math.abs(cf.UpVector.Z) * s.Y + math.abs(cf.LookVector.Z) * s.Z)
Model:GetBoundingBox() is world-aligned only for the model's pivot orientation. If the model
box and the part sizes disagree, that disagreement is the tell.
The same trap in reverse: a model's bounding box is not its visible pad. A 2x2-cell building
was ~7.4 x 5 studs of visible model on an 8 x 8 footprint (an invisible Footprint part sets
the box). Measure what is drawn when placing props on it.
Chunked endless maps
An endless or very large map is streamed in square chunks around the camera, at several levels
of detail (full near, lite mid, flat far), under a per-frame time budget (~4 ms). A skeleton is
in snippets/ChunkStreamer.luau. The rules below each come from a real bug.
Publish the new chunk before releasing the old one
When a chunk changes LOD, build the new copy completely, record it as the loaded copy, and only then queue the release of the old one. If the release happens inside the build job, a cancel in between leaves the new copy recorded nowhere: it's orphaned in Workspace forever, its ground z-fights the real one, and the next update builds the chunk a third time.
Never cancel a release
Releases yield (one piece per step to stay in budget). A scheduler that "cancels" a job just drops the coroutine at its last yield, leaving a half-destroyed chunk. It happens constantly while zooming, because LOD targets can flip every 0.25 s.
- Give release jobs their own tag; "clear all chunk jobs" cancels builds only.
- Mark
releasing = truebefore the first yield. When a chunk comes back in range during its release, let the release finish, then rebuild. - If a build is aborted, restore the old ground and destroy the half-built copy. Never pool it.
- Lay a cheap one-part "backstop" ground just under every chunk first, so any hole while an LOD swap is in flight shows plain ground, not the void or a water plane.
Don't clone templates you then hide
One streamer cloned each ~2.8k-part chunk template, then hid almost all its scatter with a
filter, keeping the hidden parts alive in memory. ~50 ms of clone per chunk went to parts no
one saw. Run the filter on the source child (compute its position from the transform and
GetPivot()) and never clone what it rejects. Removing scatter this way took the near-home
world from 106k parts to 41k.
Pools
- Reset everything a previous user changed on acquire: Transparency, colour, tags, scale.
A reused lite chunk kept
Transparency = 1on its ground from an old LOD swap and drew a pale-blue water plane through the map. - Store original colours in an attribute (
GroundC0) so a "mute" pass on a pooled copy never mutes twice. - Cap pools per template: 1 for whole chunks, ~6 for small pieces, ~24 for NPC rigs. Uncapped, six biomes x 2 LODs x 6 copies kept ~200k detached parts alive.
- Clear pools on quality change, on campaign reset and when the map clears.
- Never pool a copy that may hold half-built content (a template cloned before a runtime mesh bake finished). Pools reuse it forever.
Priorities and quality
- Re-layer and repair jobs run ahead of new streaming, so a long camera jump doesn't show bare baseplate under a battle for 12 s.
- Dropping a quality level during heavy scenes helps, but the drop and the restore both queue dozens of jobs. Stagger them, or the start and end of the scene hitch.
- Strip template scatter where gameplay needs clarity: "what you see is what blocks". Only cells that block movement get trees.
Checklist
- One Scale module; the scaler is idempotent; variants and lights scaled too.
- No discovery by fixed parent; every empty lookup warns.
- Tag caches rebuilt on a timer under StreamingEnabled.
- World extents computed from CFrame axes, not
Size. - Chunk streamer: publish-then-release, uncancellable releases, filter before clone, reset-on-acquire, capped and cleared pools.