By Shane Brumback · · Intermediate Three.js tutorial
To scatter pickups across a Three.js terrain fairly, build one spot-finder that raycasts down onto the ground, rejects water and tree trunks, and throws out any candidate too close to a pack of the same type. This tutorial pulls that pattern from Super Soldier Battle's real pickup module.
Raycast placement, rejection sampling with squared distances, a near-trees cover rule for rare items, fallback tiers so the map is never empty, and a guarded loop that tops up ammo from banked packs — simplified so you can drop it into your own game.
SEE IT LIVE
The scattered markers drifting behind this page are a live run of this exact spot-finder: random points raycast onto a terrain, rejected when they clump, with the rare amber markers kept near the trees. Or drop into Super Soldier Battle and hunt the real health, ammo and shield packs across the island. Both are free and run right in your browser, no download.
The markers behind this page are the real placement code: spacing-rejected, raycast onto the ground, shields near trees.
Introduction
Super Soldier Battle spreads health packs, ammo crates and shield packs across a big island. The first version just picked random spots, and random isn't the same as fair: packs could land on top of each other while whole areas had nothing. In this tutorial we'll build the placement code the game uses now, simplified from the real module.
It comes down to one spot-finder function with three optional rules, plus a few fallback passes:
Land on real ground. Raycast straight down onto the terrain, and reject water and tree trunks.
Keep same-type packs apart. Reject a candidate if it's within minDist of a pack of the same type.
Hide rare items in cover. For shields, only accept spots near a tree.
Never leave the map empty. If the rules are too strict, relax them step by step.
What you should know first: this is an intermediate build. The game runs on Three.js r128, and everything here works there; the runnable example and the live background demo use a current CDN build.
What You'll Learn
Finding terrain height at a random XZ point with a downward THREE.Raycaster
Rejection sampling with squared distances (no Math.sqrt)
Requiring a spot to be near something (trees) while also keeping it away from it (trunk colliders)
Fallback tiers so spawning never silently fails
A guarded while loop that consumes inventory items to keep a value topped up
Prerequisites
Comfortable with Three.js basics (scene, meshes, Raycaster) and plain JavaScript. The game runs on Three.js r128, and everything here works there. Names follow the game code, simplified. The runnable example and the live background demo use a current CDN build.
Frequently Asked Questions
How do I find the ground height at a point in Three.js?
Cast a THREE.Raycaster straight down from above the terrain and read hit.point.y from the first intersection. Super Soldier Battle casts from y = 1200 with direction (0, -1, 0) and rejects hits below -2 so packs never land in the water.
How do I keep random spawn points from clustering?
Use rejection sampling: pick a random point and throw it away if it's within a minimum distance of an existing item of the same type. Compare squared distances, and run that check before the expensive raycast.
What if my spacing rule is too strict to place everything?
Add fallback tiers. Super Soldier Battle tries with all rules first, then drops the near trees rule, then drops spacing entirely, so a crowded map still gets its packs.
How do I spawn items near trees but not inside them?
Loop over the tree trunk positions once per candidate. Reject the spot if it's inside a trunk's margin (3.5 units in the game), and only accept it if at least one trunk is within your cover radius (22 units).
How should automatic ammo refills work?
Refill in fixed amounts and consume a banked pack only when there's room for it. The game adds 250 per pack up to 1,000, and auto-pulls a pack whenever ammo drops to 750 or below, in a loop with a guard counter.
How It Works
Pick a random point and raycast down — a random X and Z inside the playable area, then a ray from high above straight down gives the ground height.
One spot-finder, three optional rules — minDist + avoid for spacing, nearTree for cover, and the trunk loop that rejects trees either way.
Spawn with spacing, then relax — each type builds its avoid list from live packs, and the spawner falls back in tiers instead of giving up.
Keep the gun topped up — each pack adds a fixed amount, and a guarded while loop pulls banked packs as the gun drains.
That's the whole system. The detailed code breakdown and the full copy-paste example are below — sign in free to unlock them.
Sign in to unlock the code
This tutorial is 100% free — just sign in with Google to reveal the full code breakdown, the complete copy-paste code, and the one-click Launch. It takes one click and unlocks every free tutorial.
Code Breakdown
Here is the whole system in four steps. The snippets are simplified from Super Soldier Battle's module/super-soldier-battle-forest-mountain-module.js, and the variable names follow the game code.
Step 1: Pick a Random Point and Raycast Down to the Ground
Start with a random X and Z inside the playable area, then cast a ray from high above straight down. The first hit gives you the ground height. The game keeps packs within ±1200 units on its larger terrain (±500 on the original one), and it rejects anything below y = -2 because water sits at -5.
// Simplified from Super Soldier Battle (module/super-soldier-battle-forest-mountain-module.js)
const HALF = 1200; // playable half-width of the island
const ray = new THREE.Raycaster();
ray.firstHitOnly = true; // stop at the first hit (used with three-mesh-bvh)
const DOWN = new THREE.Vector3(0, -1, 0);
function groundY(x, z, groundMeshes) {
ray.set(new THREE.Vector3(x, 1200, z), DOWN);
const hit = ray.intersectObjects(groundMeshes, false)[0];
if (!hit) return null; // ray missed: caller retries
if (hit.point.y < -2) return null; // water / shoreline: require solid ground
return hit.point.y;
}
Returning null instead of throwing matters. A miss is normal, so the caller just tries another random point.
Step 2: One Spot-Finder With Optional Rules
The real function is window._randomWorldMedSpot(opts). Every pack type uses it, and the options turn on the extra rules:
minDist + avoid: reject the candidate if it's within minDist of any point in avoid
nearTree: only accept spots within 22 units of a pine trunk
Compare squared distances so you never need a square root, and do the cheap distance check before the raycast so rejected points cost almost nothing.
function randomSpot(opts = {}, groundMeshes, trunks) {
const minD2 = opts.minDist > 0 ? opts.minDist * opts.minDist : 0;
const x = Math.random() * HALF * 2 - HALF;
const z = Math.random() * HALF * 2 - HALF;
// Spread rule: too close to a same-type pack? Retry. (Cheap, so it runs first.)
if (minD2 && opts.avoid) {
for (const p of opts.avoid) {
const dx = p.x - x, dz = p.z - z;
if (dx * dx + dz * dz < minD2) return null;
}
}
const y = groundY(x, z, groundMeshes);
if (y === null) return null;
// Trees: never inside a trunk (collider radius 2, so use a 3.5 margin)...
const MIN2 = 3.5 * 3.5, NEAR2 = 22 * 22;
let nearOk = false;
for (const t of trunks) {
const dx = t.x - x, dz = t.z - z, d2 = dx * dx + dz * dz;
if (d2 < MIN2) return null;
if (opts.nearTree && d2 < NEAR2) nearOk = true; // ...but optionally NEAR one
}
if (opts.nearTree && !nearOk) return null; // rare packs hide in the forest
return { x, y, z };
}
The same trunk loop does two jobs. It throws out spots that would put a pack inside a tree, and for shields it also checks that at least one tree is close enough to give cover. In the game, trunk positions come from window._pineForest.trunkPositions() and are cached for 5 seconds, because the trees don't move after they snap to the ground.
Step 3: Spawn With Spacing, Then Relax the Rules
Each pack type builds its avoid list from the packs of that type that are already on the map, then tries to place a new one. The game uses these values:
Pack
Count on map
minDist
Near trees
Health (world med packs)
respawns one elsewhere on pickup
120
no
Ammo crates
25
120
no
Shield packs
8 (was 25)
280
yes
Strict rules can fail on a crowded map, so the spawner falls back in tiers instead of giving up. Here's the shield version, which has the most tiers:
const SHIELD_MIN_DIST = 280; // hardest to find: very spread out...
function spawnOneShield(shieldPacks, groundMeshes, trunks, spawnAt) {
const avoid = Object.values(shieldPacks).map(p => ({ x: p.x, z: p.z }));
const tiers = [
{ tries: 60, opts: { minDist: SHIELD_MIN_DIST, avoid, nearTree: true } }, // ...and tucked near trees
{ tries: 30, opts: { minDist: SHIELD_MIN_DIST, avoid } }, // keep spread, drop nearTree
{ tries: 20, opts: {} }, // last resort: no spacing
];
for (const tier of tiers) {
for (let i = 0; i < tier.tries; i++) {
const spot = randomSpot(tier.opts, groundMeshes, trunks);
if (spot) { spawnAt(spot); return true; }
}
}
return false;
}
Health packs use two tiers (60 tries with spacing, then 20 without). Ammo crates are placed in one batch at load time, and the list of spots found so far doubles as the avoid list: up to 300 tries with spacing, then up to 80 more without it to fill the quota of 25.
const ammoSpots = [];
let tries = 0;
while (ammoSpots.length < 25 && tries++ < 300) {
const s = randomSpot({ minDist: 120, avoid: ammoSpots }, groundMeshes, trunks);
if (s) { ammoSpots.push(s); spawnAmmoCrateAt(s); }
}
let relax = 0;
while (ammoSpots.length < 25 && relax++ < 80) { // fallback: fill without spacing
const s = randomSpot({}, groundMeshes, trunks);
if (s) { ammoSpots.push(s); spawnAmmoCrateAt(s); }
}
Step 4: Keep the Gun Topped Up From Banked Packs
Spreading packs out only helps if picking them up feels good. In the game the gun holds 1,000 rounds and drains while the trigger is held. Each banked ammo pack adds 250 (capped at the max), and a pack is only consumed if there was room for it:
Then, every frame after the drain, pull packs as soon as the gun is down by a full pack's worth. A while loop handles a big deficit, and a guard counter makes sure it can never spin forever:
// After draining ammo this frame (and clamping it at 0):
if (inventory.ammo > 0) {
let guard = 0;
while (ammo <= AMMO_MAX - AMMO_REFILL && inventory.ammo > 0 && guard < 20) {
if (!useConsumable('ammo')) break; // stop if a pack couldn't be used
guard++;
}
}
if (ammo <= 0) fireSound.pause(); // truly empty, with no packs left
Before this change, the game refilled to max on pickup and only used a banked pack once the gun hit zero. Topping up at 750 keeps the player fighting on a nearly full gun for as long as they carry packs.
The Full Code
Copy this into a file named index.html and open it in any modern browser — or hit Launch. Three.js loads from a CDN via the import map, so it runs as-is. It scatters health, ammo and shield markers over a bumpy terrain using the exact spot-finder from the breakdown: spacing per type, the rare amber shields kept near the trees, and fallback tiers so the map fills. Drag to orbit.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scatter Pickups Across a Three.js Terrain</title>
<style>
html, body { margin: 0; height: 100%; background: #070b14; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
</style>
</head>
<body>
<canvas id="view"></canvas>
<script async src="https://unpkg.com/es-module-shims@1.8.0/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.186.1/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.186.1/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
// ============================================================
// SCENE / RENDERER
// ============================================================
const canvas = document.getElementById("view");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x070b14);
scene.fog = new THREE.FogExp2(0x070b14, 0.0016);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 4000);
camera.position.set(0, 240, 420);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.35;
scene.add(new THREE.HemisphereLight(0x8899bb, 0x223044, 1.1));
const sun = new THREE.DirectionalLight(0xffffff, 0.7);
sun.position.set(300, 600, 200);
scene.add(sun);
// ============================================================
// TERRAIN: a bumpy plane. In a real game this is your world
// mesh; here a little sine noise gives the raycast something
// with height to land on. Water sits at y = -5.
// ============================================================
const HALF = 420; // playable half-width (demo scale)
const seg = 120;
const terrainGeo = new THREE.PlaneGeometry(HALF * 2, HALF * 2, seg, seg);
terrainGeo.rotateX(-Math.PI / 2);
const pos = terrainGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), z = pos.getZ(i);
const h = Math.sin(x * 0.012) * 22 + Math.cos(z * 0.015) * 20 + Math.sin((x + z) * 0.006) * 14;
pos.setY(i, h);
}
terrainGeo.computeVertexNormals();
const terrain = new THREE.Mesh(terrainGeo, new THREE.MeshStandardMaterial({ color: 0x2b3a2e, flatShading: false }));
scene.add(terrain);
const groundMeshes = [terrain];
// water plane at y = -5 (anything below y = -2 is rejected)
const water = new THREE.Mesh(
new THREE.PlaneGeometry(HALF * 2, HALF * 2),
new THREE.MeshBasicMaterial({ color: 0x1b3a52, transparent: true, opacity: 0.6 })
);
water.rotation.x = -Math.PI / 2; water.position.y = -5; scene.add(water);
// ============================================================
// THE SPOT-FINDER (from the breakdown)
// Declared before the trees because seeding the trees below
// calls groundYRaw(), which uses `ray`.
// ============================================================
const ray = new THREE.Raycaster();
const DOWN = new THREE.Vector3(0, -1, 0);
// raw ground height with no rejection (used to seed the trees)
function groundYRaw(x, z) {
ray.set(new THREE.Vector3(x, 1200, z), DOWN);
const hit = ray.intersectObjects(groundMeshes, false)[0];
return hit ? hit.point.y : null;
}
function groundY(x, z) {
const y = groundYRaw(x, z);
if (y === null) return null;
if (y < -2) return null; // water / shoreline
return y;
}
// ============================================================
// TREES: a cluster of trunks. Shields must spawn NEAR a trunk
// (within 22 units) but never INSIDE one (within 3.5 units).
// ============================================================
const trunks = [];
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x3a2a1c });
const leafMat = new THREE.MeshStandardMaterial({ color: 0x244a2c });
for (let i = 0; i < 70; i++) {
const a = Math.random() * Math.PI * 2;
const r = 90 + Math.random() * 280;
const x = Math.cos(a) * r, z = Math.sin(a) * r;
// ground height for the trunk base
const gy = groundYRaw(x, z);
if (gy === null) continue;
trunks.push({ x, z });
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(2, 3, 26, 6), trunkMat);
trunk.position.set(x, gy + 13, z); scene.add(trunk);
const leaves = new THREE.Mesh(new THREE.ConeGeometry(11, 30, 7), leafMat);
leaves.position.set(x, gy + 34, z); scene.add(leaves);
}
function randomSpot(opts = {}) {
const minD2 = opts.minDist > 0 ? opts.minDist * opts.minDist : 0;
const x = Math.random() * HALF * 2 - HALF;
const z = Math.random() * HALF * 2 - HALF;
if (minD2 && opts.avoid) {
for (const p of opts.avoid) {
const dx = p.x - x, dz = p.z - z;
if (dx * dx + dz * dz < minD2) return null; // too close to a same-type pack
}
}
const y = groundY(x, z);
if (y === null) return null;
const MIN2 = 3.5 * 3.5, NEAR2 = 22 * 22;
let nearOk = false;
for (const t of trunks) {
const dx = t.x - x, dz = t.z - z, d2 = dx * dx + dz * dz;
if (d2 < MIN2) return null; // never inside a trunk
if (opts.nearTree && d2 < NEAR2) nearOk = true; // optionally near one
}
if (opts.nearTree && !nearOk) return null; // rare packs hide in the forest
return { x, y, z };
}
// ============================================================
// SPAWN MARKERS. Each type keeps its own avoid list and falls
// back in tiers so the map never ends up empty.
// Health: minDist 120 (scaled down for the demo). Ammo: 120.
// Shields: 280 + nearTree. (Demo distances are shrunk to fit.)
// ============================================================
const MED = 0x00000, markers = new THREE.Group();
scene.add(markers);
function marker(spot, color) {
const m = new THREE.Mesh(
new THREE.BoxGeometry(7, 7, 7),
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5 })
);
m.position.set(spot.x, spot.y + 5, spot.z);
markers.add(m);
}
function spawnTiered(tiers, color) {
for (const tier of tiers) {
for (let i = 0; i < tier.tries; i++) {
const s = randomSpot(tier.opts);
if (s) { marker(s, color); return true; }
}
}
return false;
}
// demo distances (smaller world than the game)
const MED_MIN = 70, AMMO_MIN = 70, SHIELD_MIN = 150;
const health = [], ammo = [], shields = [];
// health: 10 packs, two tiers
for (let n = 0; n < 10; n++) {
spawnTiered([
{ tries: 60, opts: { minDist: MED_MIN, avoid: health } },
{ tries: 20, opts: {} },
], 0xff4444) && health.push(markers.children[markers.children.length - 1].position);
}
// ammo: 14 crates, spacing then relax
let tries = 0;
while (ammo.length < 14 && tries++ < 300) {
const s = randomSpot({ minDist: AMMO_MIN, avoid: ammo });
if (s) { marker(s, 0x33ddff); ammo.push({ x: s.x, z: s.z }); }
}
let relax = 0;
while (ammo.length < 14 && relax++ < 80) {
const s = randomSpot({});
if (s) { marker(s, 0x33ddff); ammo.push({ x: s.x, z: s.z }); }
}
// shields: 8 packs, near trees, most tiers
for (let n = 0; n < 8; n++) {
spawnTiered([
{ tries: 60, opts: { minDist: SHIELD_MIN, avoid: shields, nearTree: true } },
{ tries: 30, opts: { minDist: SHIELD_MIN, avoid: shields } },
{ tries: 20, opts: {} },
], 0xffbb33) && shields.push({ x: markers.children[markers.children.length - 1].position.x, z: markers.children[markers.children.length - 1].position.z });
}
// ============================================================
// RENDER LOOP + RESIZE
// ============================================================
const clock = new THREE.Clock();
function frame() {
requestAnimationFrame(frame);
const t = clock.getElapsedTime();
markers.children.forEach((m, i) => {
m.rotation.y = t + i;
m.position.y += Math.sin(t * 2 + i) * 0.02;
});
controls.update();
renderer.render(scene, camera);
}
function resize() {
const w = canvas.clientWidth || 1, h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
addEventListener("resize", resize);
frame();
</script>
</body>
</html>
How To Use It In Your Game
Write one randomSpot(opts) that picks a random XZ, rejects by distance first, then raycasts down and rejects water and obstacles.
Keep one list of live positions per pickup type and pass it as avoid.
Give rare, powerful items a bigger minDist and a cover rule like nearTree.
Wrap every spawn in fallback tiers that relax the rules, so the map never ends up empty.
Make "use item" return true or false, and drive auto-use with a guarded loop.
SEE THESE PICKUPS IN A REAL GAME
This placement system ships in Super Soldier Battle's health, ammo and shield packs. Read the dev log on the pickups update to see the gameplay side of the same feature.