# Game Development Cheat Sheet — full corpus > Visual, AI-ready cheat sheets for game development and physics. Every concept has an interactive Three.js visual, formulas, code and a copy-ready prompt. # Billboarding > A billboard is a flat quad rotated every frame to face the camera, so a 2D image reads as 3D. Use full facing for sprites, Y-axis only for trees. - Category: Three.js & Rendering › Rendering - Level: common - Tags: billboard, sprite, impostor, particles, quaternion - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/billboarding ## What it is A **billboard** is a textured quad whose rotation is overwritten each frame so its face points at the viewer. Two triangles stand in for something that would cost thousands: particles, grass, distant trees, health bars, lens flares, and the far level of a [level of detail](https://gdcs.coody.app/threejs/rendering/level-of-detail) chain. **Spherical (full) billboards** copy the camera's rotation, so they are always screen-aligned, even when you look down on them. That suits particles, sprites and UI markers. `THREE.Sprite` does exactly this in its vertex shader. **Cylindrical (axis-aligned) billboards** only rotate around the world up axis, so they stay upright: right for trees, characters and anything that would look wrong tipping over when the camera rises. Copying the camera quaternion makes all billboards parallel to the screen plane. Pointing each one at the camera position instead (`lookAt`) is subtly different: quads near the screen edges turn toward the eye. Parallel is cheaper and avoids sorting artefacts in particles; point-at looks better for large, close quads. For thousands of billboards do it on the GPU: build the quad corners in the vertex shader from the camera's right and up vectors (rows of the view matrix), and draw everything as one instanced mesh or `Points`. ## Key formulas - **Spherical corner:** $\mathbf{p} = \mathbf{c} + x\,s\,\hat{R}_{cam} + y\,s\,\hat{U}_{cam}$ — c is the billboard centre, (x, y) the corner in ±0.5, s its size. - **Cylindrical yaw:** $\psi = \operatorname{atan2}(e_x - c_x,\; e_z - c_z)$ — Rotate only around +Y by ψ; e is the eye position. ## Rules of thumb - Particles, sprites, icons: spherical. Trees, grass, characters, signs: cylindrical. - Cross two or three quads (an X or star) for vegetation seen from above; fade to a single billboard far away. - Alpha-tested foliage (`alphaTest ≈ 0.5`) avoids sorting; true transparency needs back-to-front order. - Size particles in world units for 3D feel, in pixels (`sizeAttenuation: false`) for UI markers. - Billboard in the vertex shader once you pass about a thousand quads. ## Implementation Spherical and cylindrical billboards on the CPU: ```ts const eye = new THREE.Vector3() const pos = new THREE.Vector3() function faceCamera(quads: THREE.Object3D[], camera: THREE.Camera, mode: "full" | "y") { camera.getWorldPosition(eye) for (const q of quads) { if (mode === "full") { // Parallel to the screen: same rotation as the camera. q.quaternion.copy(camera.quaternion) // assumes q's parent is unrotated } else { q.getWorldPosition(pos) q.rotation.set(0, Math.atan2(eye.x - pos.x, eye.z - pos.z), 0) } } } ``` GPU version: expand a point into a camera-facing quad. ```glsl attribute vec2 corner; // (-0.5..0.5) per vertex uniform float uSize; void main() { vec3 right = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); vec3 up = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); // Cylindrical variant: up = vec3(0.0, 1.0, 0.0); vec3 center = (modelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; vec3 world = center + (right * corner.x + up * corner.y) * uSize; gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0); } ``` ## Common mistakes - Using full billboards for trees, which then lean back when the camera flies over them. - Copying the camera quaternion into a quad whose parent is rotated; convert to the parent's space first. - Billboards that are close and large: their flatness shows when they intersect geometry. Use soft particles or real meshes up close. - Unsorted transparent billboards popping in front of each other; alpha-test or sort. ## Interactive visual Tree cards stand on a turning platform. Green is a card's front, red its back; the yellow tick shows which way each card faces. With 'off' you see cards edge-on and from behind, 'Y-axis' keeps them upright and facing you, 'full' also tilts them toward the elevated camera. Raise the camera angle to exaggerate the difference. ## Prompt ```text Write a TypeScript Three.js `BillboardSystem` that keeps a set of quads facing the camera. Support two modes per quad: 'spherical' (copy the camera's world quaternion, converted into the quad parent's local space with the parent's inverse world quaternion) and 'cylindrical' (yaw only around world +Y using atan2 of the eye-to-quad vector on XZ, also converted for rotated parents). Update in one loop per frame with no allocations. Then add an alternative GPU path: an InstancedMesh with a vertex shader that builds the quad from the view matrix right/up vectors, with a uniform to switch to cylindrical (up = +Y). Include a test: with the camera directly above a cylindrical billboard it stays vertical, while a spherical one becomes horizontal. ``` ## Related - [Level of Detail (LOD)](https://gdcs.coody.app/threejs/rendering/level-of-detail.md) — LOD swaps a mesh for cheaper versions as it shrinks on screen, so far objects cost a fraction of near ones. Hysteresis stops the swaps flickering. - [Particle System](https://gdcs.coody.app/systems/patterns/particle-system.md) — An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. - [Quaternions](https://gdcs.coody.app/mathematics/rotations/quaternions.md) — A unit quaternion stores a 3D rotation as an axis and half-angle. It composes cheaply, never gimbal-locks and slerps along the shortest arc. ## Further reading - [Three.js docs — Sprite](https://threejs.org/docs/#api/en/objects/Sprite) - [Three.js docs — Points and PointsMaterial](https://threejs.org/docs/#api/en/objects/Points) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Fragment Shaders > A fragment shader computes the colour of each pixel a triangle covers. Feed it UVs, normals and a time uniform and it paints anything. - Category: Three.js & Rendering › Rendering - Level: common - Tags: shader, glsl, shadermaterial, uniforms, gpu - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/fragment-shader ## What it is After the vertex shader positions a triangle, the GPU rasterizes it into **fragments**, one per covered pixel (or per sample with MSAA). The fragment shader runs for each of them in parallel and outputs a colour. It cannot see its neighbours or keep state between frames; everything it knows comes from **varyings** (values interpolated from the vertices, like UVs and normals), **uniforms** (the same for every pixel in a draw, like time or a colour) and textures. In Three.js, `ShaderMaterial` lets you write both shaders in GLSL while Three.js injects the common matrices and attributes (`position`, `uv`, `normal`, `projectionMatrix`...). `RawShaderMaterial` injects nothing. To animate, update a uniform each frame, e.g. `material.uniforms.uTime.value = clock.elapsedTime`, never recompile the shader. The building blocks are small: `fract` for repetition, `step`/`smoothstep` for edges, `mix` for blending, `length(uv - 0.5)` for circles, `sin(x + uTime)` for motion and a hash or noise function for organic variation. A `discard` drops the fragment entirely, which is how dissolve and cut-out effects work. Cost scales with **pixels**, not objects: a full-screen effect at 1080p runs about two million times per frame. Keep loops short, avoid dependent texture reads where possible, and watch [overdraw](https://gdcs.coody.app/performance/gpu/overdraw) from stacked transparent quads. ## Key formulas - **Smooth edge:** $\operatorname{smoothstep}(e_0, e_1, x) = t^2(3 - 2t),\; t = \operatorname{clamp}\!\left(\tfrac{x - e_0}{e_1 - e_0}, 0, 1\right)$ - **Ripples:** $c = \tfrac12 + \tfrac12\sin\!\big(k\,\lVert uv - \tfrac12\rVert - \omega t\big)$ — k sets ring density, ω the speed. Subtracting ωt moves rings outward. - **Fragment count:** $N \approx W \cdot H \cdot \text{overdraw}$ ## Rules of thumb - Name uniforms `uSomething`, varyings `vSomething`; it saves hours of confusion. - Pass `uTime` in seconds and wrap it (`mod(uTime, 1000.0)`) to keep `sin` precise on mobile. - Use `smoothstep` with a width of `fwidth(x)` for edges that stay crisp at any zoom. - Start with `precision highp float;` (Three.js adds it); mediump banding shows on large UV ranges. - Output linear colour and let the renderer convert, or include `` in custom shaders. ## Implementation A plane with an animated shader: ```ts const material = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(0x38bdf8) }, }, vertexShader: /* glsl */ ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: /* glsl */ ` uniform float uTime; uniform vec3 uColor; varying vec2 vUv; void main() { float d = length(vUv - 0.5); float rings = 0.5 + 0.5 * sin(d * 40.0 - uTime * 4.0); gl_FragColor = vec4(uColor * rings, 1.0); }`, }) const plane = new THREE.Mesh(new THREE.PlaneGeometry(4, 4), material) renderer.setAnimationLoop((t) => { material.uniforms.uTime.value = t / 1000 renderer.render(scene, camera) }) ``` Dissolve: hash noise against a moving threshold, with a glowing edge. ```glsl uniform float uTime; varying vec2 vUv; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } void main() { float n = hash(floor(vUv * 48.0)); float cut = 0.5 + 0.5 * sin(uTime); // 0..1 if (n < cut) discard; // dissolved float edge = smoothstep(cut + 0.08, cut, n); // 1 near the cut gl_FragColor = vec4(mix(vec3(0.6), vec3(1.0, 0.5, 0.1), edge), 1.0); } ``` ## Common mistakes - Creating a new `ShaderMaterial` (and a recompile) to change a value instead of updating a uniform. - Integer literals in GLSL ES: `1` is not `1.0`, and mixing them fails to compile. - Branching per pixel on data that varies wildly; both branches may run. Prefer `mix` and `step`. - Forgetting `transparent: true` when writing alpha < 1, so the alpha is ignored. - Testing only on a desktop GPU; mobile precision and fill rate are far lower. ## Interactive visual One plane, one ShaderMaterial, three fragment programs: every pixel you see is computed from its UV and the uTime uniform. Switch the effect, change the speed (it only scales the uniform), and raise the scale to see how cheap repetition is with fract and sin. ## Prompt ```text Create a reusable Three.js ShaderMaterial in TypeScript called `makeEffectMaterial(effect: 'ripples' | 'plasma' | 'dissolve')`. Uniforms: uTime (seconds), uSpeed, uScale and uColor (THREE.Color). Write a minimal vertex shader that passes vUv, and one fragment shader with a `#define EFFECT` switch chosen at creation time (no runtime branching). Ripples: concentric sin rings from the centre; plasma: sum of three sin waves over uv and time; dissolve: hashed cell noise with discard and an orange edge band using smoothstep. Wrap time with mod to keep precision. Provide an `update(material, elapsed)` function that only writes uTime. Add a test that the generated shader source contains the chosen define and that update does not create new objects. ``` ## Related - [Surface Normals](https://gdcs.coody.app/threejs/rendering/normals.md) — A normal is a unit vector perpendicular to a surface. Lighting, slope checks, bounces and decals all start from it, so it must be right. - [Overdraw](https://gdcs.coody.app/performance/gpu/overdraw.md) — Overdraw is shading the same pixel more than once. Stacked transparent particles and big quads multiply fill cost; trim sprites and limit layers. - [Billboarding](https://gdcs.coody.app/threejs/rendering/billboarding.md) — A billboard is a flat quad rotated every frame to face the camera, so a 2D image reads as 3D. Use full facing for sprites, Y-axis only for trees. ## Further reading - [Three.js docs — ShaderMaterial](https://threejs.org/docs/#api/en/materials/ShaderMaterial) - [Khronos — GLSL ES 3.00 specification (PDF)](https://registry.khronos.org/OpenGL/specs/es/3.0/GLSL_ES_Specification_3.00.pdf) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Frustum Culling > Frustum culling skips objects whose bounding volume lies fully outside the camera's six view planes, before any draw call is made. - Category: Three.js & Rendering › Rendering - Level: core - Tags: culling, frustum, bounding sphere, visibility, optimization - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/frustum-culling ## What it is A perspective camera sees a truncated pyramid, the **view frustum**, bounded by six planes: near, far, left, right, top and bottom. Anything entirely outside it cannot reach a pixel, so the CPU can drop it before sending a [draw call](https://gdcs.coody.app/performance/gpu/draw-calls). The test is cheap because it uses a bounding volume, not the mesh. For a **bounding sphere**, compute the signed distance from its centre to each plane; if any distance is below `-r`, the sphere is fully outside and the object is culled. Spheres can report false positives near the frustum corners, which is fine: drawing one extra object is harmless, skipping a visible one is a bug. Three.js does this automatically for every object with `frustumCulled = true` (the default), using `geometry.boundingSphere`. It is computed once from the original vertices, so if you move vertices in a shader, skin a mesh, or use `InstancedMesh` spread over a large area, the sphere is wrong and objects vanish at screen edges. Frustum culling does not handle objects hidden **behind** other objects; that is occlusion culling. With thousands of objects, test a hierarchy (BVH, octree, grid cells, see [spatial partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning)) so a single test rejects a whole region. ## Key formulas - **Plane distance:** $d_i = \hat{n}_i \cdot \mathbf{c} + w_i$ — Plane i stored as (n, w) with n pointing into the frustum. - **Sphere test:** $\text{culled} \iff \exists\, i \in 1..6 : d_i < -r$ - **Planes from the matrix:** $M = P\,V, \quad \text{left} = M_{row4} + M_{row1}, \;\; \text{right} = M_{row4} - M_{row1}$ — Same pattern for rows 2 (bottom/top) and 3 (near/far). Normalize each plane afterwards. ## Rules of thumb - Test bounding spheres first (6 dot products); refine with AABBs only for long, thin objects. - Keep `far` as small as the game allows; it is both a culling plane and a depth-precision setting. - Pad bounds of animated or vertex-displaced meshes by the maximum displacement. - Shadow-casting lights need their own frustum; an object off screen can still cast a visible shadow. - Cull by cluster or grid cell when object counts exceed a few thousand. ## Implementation Manual frustum test, e.g. to pause AI or audio for off-screen objects: ```ts const frustum = new THREE.Frustum() const viewProj = new THREE.Matrix4() const sphere = new THREE.Sphere() function updateFrustum(camera: THREE.Camera) { camera.updateMatrixWorld() viewProj.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) frustum.setFromProjectionMatrix(viewProj) } function isOnScreen(mesh: THREE.Mesh) { const geo = mesh.geometry if (!geo.boundingSphere) geo.computeBoundingSphere() sphere.copy(geo.boundingSphere!).applyMatrix4(mesh.matrixWorld) return frustum.intersectsSphere(sphere) } // Vertex-displaced mesh: grow the sphere instead of disabling culling. waterMesh.geometry.computeBoundingSphere() waterMesh.geometry.boundingSphere!.radius += maxWaveHeight ``` ## Common mistakes - Setting `frustumCulled = false` on everything to fix a popping mesh instead of fixing its bounds. - Forgetting `computeBoundingSphere()` after editing a geometry's positions, so the old bounds cull it. - Culling against a stale camera: update matrices before building the frustum. - Assuming culled objects are free. Their scripts, animation and physics still run unless you gate them too. - Expecting frustum culling to remove objects hidden behind a wall (that needs occlusion culling). ## Interactive visual A top-down view of a game camera (orange) and its frustum wedge. Objects whose bounding sphere touches the wedge are drawn in cyan; the faded grey ones are culled and would cost no draw call. Narrow the field of view or shorten the far plane to cull more; turn off sweep to read the exact count. ## Prompt ```text Write a TypeScript Three.js module that culls a list of game entities each frame. Build a THREE.Frustum from camera.projectionMatrix × camera.matrixWorldInverse after calling camera.updateMatrixWorld(). For each entity, transform its geometry's bounding sphere (computed once and padded by an optional `padding` value) into world space and test it; set `entity.mesh.visible` and an `entity.onScreen` flag that game code can use to throttle updates. No allocations per frame. Edge cases: objects straddling a plane count as visible; the camera inside an object's sphere counts as visible. Include a Vitest test with a camera at the origin looking down -Z: a sphere at (0,0,-5) is visible, one at (0,0,5) is culled, one at (100,0,-5) is culled. ``` ## Related - [Level of Detail (LOD)](https://gdcs.coody.app/threejs/rendering/level-of-detail.md) — LOD swaps a mesh for cheaper versions as it shrinks on screen, so far objects cost a fraction of near ones. Hysteresis stops the swaps flickering. - [Draw Calls](https://gdcs.coody.app/performance/gpu/draw-calls.md) — Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info. - [Spatial Partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning.md) — Bin objects into a uniform grid so collision and proximity queries test only same-cell and neighbour-cell pairs instead of all n(n−1)/2 pairs. ## Further reading - [Three.js docs — Frustum](https://threejs.org/docs/#api/en/math/Frustum) - [MDN — WebGL model, view, projection](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_model_view_projection) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Level of Detail (LOD) > LOD swaps a mesh for cheaper versions as it shrinks on screen, so far objects cost a fraction of near ones. Hysteresis stops the swaps flickering. - Category: Three.js & Rendering › Rendering - Level: common - Tags: lod, triangles, optimization, hysteresis, impostor - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/level-of-detail ## What it is A far-away object covers a handful of pixels, so drawing all of its triangles wastes vertex work and causes sub-pixel triangles that shade badly. **Level of detail** keeps several versions of a mesh (LOD0 full, LOD1 about half, LOD2 about a quarter, often a flat **impostor** last) and picks one per frame from the camera distance or projected screen size. `THREE.LOD` does the picking: add levels with `addLevel(mesh, distance)` and it shows the highest level whose distance the camera has passed, during `scene` update (`lod.autoUpdate`, on by default). Distances are in world units, so tune them against your field of view and resolution. Screen-size based selection, as many engines use, scales automatically with both. Without care, an object sitting right on a threshold pops back and forth every frame as the camera bobs. **Hysteresis** fixes that: the switch-back distance is a bit shorter than the switch-out distance, so a small wobble cannot toggle the level. In Three.js the third `addLevel` argument is that band as a fraction of the distance. LOD trims vertex cost and sub-pixel aliasing; it does nothing for [draw calls](https://gdcs.coody.app/performance/gpu/draw-calls) unless far levels are also merged or instanced. Pair it with [frustum culling](https://gdcs.coody.app/threejs/rendering/frustum-culling) so off-screen objects cost nothing at all. ## Key formulas - **Projected size:** $s_{px} = \frac{r}{d\,\tan(\tfrac{fov_y}{2})}\cdot \frac{H}{2}$ — Radius r at distance d, viewport height H. Halving it every level matches halving the triangle budget roughly every doubling of distance. - **Hysteresis band:** $\text{enter } i \text{ at } d \ge D_i, \quad \text{leave } i \text{ at } d < D_i(1 - h)$ ## Rules of thumb - Triangle budget per level: roughly 100% → 50% → 25% → 10% (or an impostor). - Switch when the object's on-screen height crosses about 300, 120 and 40 px; under ~10 px, cull it. - Hysteresis of 5–15% of the threshold distance removes almost all popping. - Keep silhouettes stable between levels; players notice outline changes far more than interior detail. - Bake normal maps from LOD0 onto lower levels so shading survives the triangle loss. ## Implementation Three levels with a 10% hysteresis band: ```ts const lod = new THREE.LOD() const mat = new THREE.MeshStandardMaterial({ color: 0x8899aa }) const detail = [ { geo: new THREE.IcosahedronGeometry(1, 4), distance: 0 }, // 5,120 tris { geo: new THREE.IcosahedronGeometry(1, 2), distance: 15 }, // 320 tris { geo: new THREE.IcosahedronGeometry(1, 0), distance: 40 }, // 20 tris ] for (const { geo, distance } of detail) { lod.addLevel(new THREE.Mesh(geo, mat), distance, 0.1) } scene.add(lod) // updates itself during render (lod.autoUpdate = true) // Which level is showing right now? const level = lod.getCurrentLevel() ``` Engine-neutral selection with hysteresis: ```text level = 0 for i in 1..levels.count-1: threshold = switchAt[i] if current == i: threshold *= (1 - hysteresis) // sticky once entered if distance >= threshold: level = i else break current = level ``` ## Common mistakes - Thresholds that are too close together, so the player sees every pop at normal walking speed. - Ignoring FOV: zooming a sniper scope makes distant objects large but still draws LOD2. - Low levels with different UVs or materials, causing a colour shift at the swap. - Making every prop an LOD while the bottleneck is draw calls or fill rate, not vertices. - Running LOD selection for thousands of objects each frame instead of every few frames or per cluster. ## Interactive visual The orange viewer slides back and forth past a field of spheres. Each sphere picks a mesh from its distance: cyan is full detail, violet is medium, grey is the low version. Solid rings mark the switch distances and dashed yellow rings the hysteresis band; the cyan bar on the right is the total triangle count. Set hysteresis to 0 and slow the viewer to watch the boundary pop. ## Prompt ```text Build a TypeScript LOD manager for Three.js. Input: a list of { object, levels: Mesh[], switchAtPx: number[] } plus camera and renderer size. Every 4th frame, compute each object's projected bounding-sphere height in pixels (r / (d * tan(fov/2)) * H/2, using the camera's current fov so zoom is respected) and choose a level with 10% hysteresis so a level is only left once the size crosses the threshold by that margin. Toggle `visible` on level meshes only when the choice changes, never allocate per frame, and cull objects under 8 px. Write a Vitest test that a size oscillating ±5% around a threshold never changes level. ``` ## Related - [Frustum Culling](https://gdcs.coody.app/threejs/rendering/frustum-culling.md) — Frustum culling skips objects whose bounding volume lies fully outside the camera's six view planes, before any draw call is made. - [Draw Calls](https://gdcs.coody.app/performance/gpu/draw-calls.md) — Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info. - [Billboarding](https://gdcs.coody.app/threejs/rendering/billboarding.md) — A billboard is a flat quad rotated every frame to face the camera, so a 2D image reads as 3D. Use full facing for sprites, Y-axis only for trees. ## Further reading - [Three.js docs — LOD](https://threejs.org/docs/#api/en/objects/LOD) - [GPU Gems 2 — Terrain rendering using GPU-based geometry clipmaps](https://developer.nvidia.com/gpugems/gpugems2/part-i-geometric-complexity/chapter-2-terrain-rendering-using-gpu-based-geometry-clipmaps) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Raycasting > A ray is an origin plus a direction. Raycasting returns the nearest surface it crosses, with hit point, distance and surface normal. - Category: Three.js & Rendering › Rendering - Level: core - Tags: ray, intersection, picking, line of sight, hitscan - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/raycasting ## What it is A ray is the half-line `P(t) = O + t·D` for `t ≥ 0`. A raycast tests that line against colliders or triangles and returns the **closest** hit: the point, the distance `t`, the object, and the surface normal at that point. Games use it everywhere: hitscan weapons, mouse picking, line-of-sight checks, ground detection under a character, camera collision, and placing objects on terrain. In Three.js, `THREE.Raycaster` does this against meshes. Physics engines offer faster versions against their own colliders. Raycasts are cheap individually but add up. Limit the distance, filter by layer or mask, and test against simple proxy shapes, or an acceleration structure such as a BVH, rather than every triangle of a detailed mesh. ## Key formulas - **Ray:** $P(t) = O + t\,\hat{D}, \quad t \ge 0$ - **Ray–plane hit:** $t = \frac{(P_0 - O)\cdot \hat{n}}{\hat{D}\cdot \hat{n}}$ — No hit when the denominator is ~0 (parallel ray) or t < 0 (plane behind the origin). - **Ray–sphere hit:** $t = -b - \sqrt{b^2 - c},\; b = (O-C)\cdot\hat{D},\; c = \lVert O-C\rVert^2 - r^2$ — A negative discriminant (b² − c < 0) means a miss. ## Rules of thumb - Always normalize the direction; most APIs assume a unit vector, so `t` is in world units. - Set `far` (max distance) to the gameplay range, not infinity. - Ground checks: start the ray slightly above the feet and cast a little past them (e.g. 0.1–0.2 units) to tolerate small steps. - Use layers/masks so the player's own collider doesn't block their shots. ## Implementation Mouse picking with `THREE.Raycaster`: ```ts const raycaster = new THREE.Raycaster() const pointer = new THREE.Vector2() window.addEventListener("pointerdown", (e) => { // Pixel → normalized device coordinates (-1..1) pointer.x = (e.clientX / innerWidth) * 2 - 1 pointer.y = -(e.clientY / innerHeight) * 2 + 1 raycaster.setFromCamera(pointer, camera) raycaster.far = 100 const [hit] = raycaster.intersectObjects(pickables, false) if (hit) { console.log(hit.object.name, hit.point, hit.distance, hit.face?.normal) } }) ``` Engine-neutral ground check: ```text origin = feet + up * 0.1 hit = raycast(origin, -up, maxDistance = 0.3, mask = WORLD) grounded = hit != null and dot(hit.normal, up) > 0.7 // walkable slope ``` ## Common mistakes - Forgetting the Y flip when converting mouse coordinates to NDC. - Using `intersectObjects(scene.children, true)` every frame on detailed meshes. Raycast against simple proxies or a BVH instead. - Starting the ray inside a collider. Many engines ignore or report odd hits for backfaces and inside starts. - Expecting `face.normal` in world space. In Three.js it is in object space, so transform it with the object's normal matrix. ## Interactive visual A ray sweeps from the white origin. The cyan segment ends at the first surface it hits; the orange dot is the hit point and the green arrow is that surface's normal. Turn off auto sweep to aim it yourself, and shorten max distance to see misses. ## Prompt ```text Add raycast-based interaction to my Three.js scene in TypeScript. On pointerdown, convert the pointer to NDC (flip Y), call raycaster.setFromCamera, limit raycaster.far to 100, and intersect only an explicit `pickables` array (non-recursive). For the closest hit, log object name, world-space hit point, distance, and the world-space normal (transform face.normal by the object's normal matrix). Also write a reusable `groundCheck(origin, maxDistance)` that casts straight down and treats the hit as walkable if dot(normal, up) > 0.7. No per-frame allocations. ``` ## Related - [Surface Normals](https://gdcs.coody.app/threejs/rendering/normals.md) — A normal is a unit vector perpendicular to a surface. Lighting, slope checks, bounces and decals all start from it, so it must be right. - [Tunneling & Continuous Collision](https://gdcs.coody.app/systems/collision/tunneling.md) — A fast object can be on one side of a thin wall at one tick and past it at the next. Swept tests (CCD) check the whole path between positions. - [Hitboxes & Hurtboxes](https://gdcs.coody.app/systems/collision/hitbox-hurtbox.md) — Attacks own hitboxes that exist only during active frames; characters own hurtboxes. An overlap is a hit, registered once per swing per target. ## Further reading - [Three.js docs — Raycaster](https://threejs.org/docs/#api/en/core/Raycaster) - [Scratchapixel — Ray-sphere intersection](https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-sphere-intersection.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Surface Normals > A normal is a unit vector perpendicular to a surface. Lighting, slope checks, bounces and decals all start from it, so it must be right. - Category: Three.js & Rendering › Rendering - Level: core - Tags: normal, lighting, slope, cross product, shading - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/normals ## What it is A **normal** is a unit vector pointing straight out of a surface. For a triangle `a, b, c` it is the normalized cross product of two edges, and the winding order (counter-clockwise in Three.js) decides which side is the front. A **face normal** is flat per triangle and gives faceted shading. A **vertex normal** averages the normals of the faces that share a vertex, so lighting interpolates smoothly across the mesh. `geometry.computeVertexNormals()` does the averaging for you, but only across vertices that are actually shared: a cube with split corners stays hard-edged, which is usually what you want. Gameplay leans on normals too. `dot(n, up)` is the cosine of the slope angle, so one dot product tells you whether ground is walkable. Reflection and bounce directions, decal orientation, sliding along walls and the hit normal from [raycasting](https://gdcs.coody.app/threejs/rendering/raycasting) all use the same vector. Normals live in object space inside the geometry. Rotating or **non-uniformly scaling** a mesh changes them, so convert with the normal matrix (the inverse transpose of the model matrix), not the model matrix itself. ## Key formulas - **Face normal:** $\hat{n} = \frac{(b - a)\times(c - a)}{\lVert (b - a)\times(c - a)\rVert}$ — Swapping b and c flips the normal. That is what back-face culling reads. - **Slope angle:** $\theta = \arccos(\hat{n}\cdot\hat{u}), \quad \text{walkable if } \hat{n}\cdot\hat{u} \ge \cos\theta_{max}$ - **Normal matrix:** $N = \left(M^{-1}\right)^{\mathsf{T}}_{3\times 3}$ — Equals the rotation part of M only when the scale is uniform. - **Reflection:** $\vec{r} = \vec{d} - 2(\vec{d}\cdot\hat{n})\,\hat{n}$ ## Rules of thumb - Max walkable slope 45° → `dot(n, up) ≥ 0.707`; 50° → 0.643; 60° → 0.5. - Re-normalize after interpolating or transforming. Lerped unit vectors get shorter. - Hard edge = duplicated vertices with different normals. Smooth edge = one shared vertex. - Smoothing groups / auto-smooth angle around 30–60° keeps bevels smooth and box corners crisp. - If a mesh looks inside-out or black, check winding first, then `material.side`. ## Implementation Face normal of a triangle and a world-space normal from a raycast: ```ts const ab = new THREE.Vector3() const ac = new THREE.Vector3() function faceNormal(a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, out: THREE.Vector3) { ab.subVectors(b, a) ac.subVectors(c, a) return out.crossVectors(ab, ac).normalize() } const normalMatrix = new THREE.Matrix3() const worldNormal = new THREE.Vector3() function hitNormal(hit: THREE.Intersection) { normalMatrix.getNormalMatrix(hit.object.matrixWorld) return worldNormal.copy(hit.face!.normal).applyMatrix3(normalMatrix).normalize() } const MAX_SLOPE_COS = Math.cos(THREE.MathUtils.degToRad(45)) const isWalkable = (n: THREE.Vector3) => n.y >= MAX_SLOPE_COS // dot(n, up) with up = +Y ``` Vertex shader: carry the normal to view space correctly. ```glsl // Three.js provides normalMatrix = inverse transpose of modelViewMatrix (3x3) varying vec3 vNormal; void main() { vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } // Fragment: normalize(vNormal) again, interpolation shortens it. ``` ## Common mistakes - Transforming normals with the model matrix on a non-uniformly scaled mesh. They tilt and lighting goes wrong. - Calling `computeVertexNormals()` on a geometry with split vertices and expecting smooth shading (or on a welded cube and getting blobby lighting). - Forgetting to normalize after interpolation, so `dot(n, up)` reads lower than the true cosine. - Treating `face.normal` from a Three.js raycast as world space. It is object space. - Inconsistent winding from an exporter, leaving half the triangles facing inward. ## Interactive visual A rolling heightfield with normal arrows sampled across it. Triangles are tinted green where dot(n, up) passes the walkable limit and red where they are too steep; yellow arrows are the per-face normals and cyan arrows the smoothed vertex normals. Lower the max slope to see more ground turn red. ## Prompt ```text In TypeScript with Three.js, write a `surfaceInfo(hit: THREE.Intersection, maxSlopeDeg = 45)` helper that returns the world-space unit normal (use the normal matrix of hit.object.matrixWorld, not matrixWorld itself), the slope angle in degrees, and `walkable = dot(n, up) >= cos(maxSlopeDeg)`. Handle hits with no face by returning null. Also write `faceNormal(a, b, c, out)` using the CCW cross product without allocating. Add a Vitest test: a triangle in the XZ plane wound CCW from above gives (0, 1, 0); a mesh scaled (1, 4, 1) still returns a unit normal perpendicular to the transformed surface; a 30° ramp is walkable and a 60° ramp is not. ``` ## Related - [Raycasting](https://gdcs.coody.app/threejs/rendering/raycasting.md) — A ray is an origin plus a direction. Raycasting returns the nearest surface it crosses, with hit point, distance and surface normal. - [Fragment Shaders](https://gdcs.coody.app/threejs/rendering/fragment-shader.md) — A fragment shader computes the colour of each pixel a triangle covers. Feed it UVs, normals and a time uniform and it paints anything. - [Z-Fighting](https://gdcs.coody.app/threejs/rendering/z-fighting.md) — Z-fighting is the shimmering stripes you get when two surfaces are closer than the depth buffer can resolve. Fix the geometry, the near plane or the bias. ## Further reading - [Three.js docs — BufferGeometry.computeVertexNormals](https://threejs.org/docs/#api/en/core/BufferGeometry.computeVertexNormals) - [Scratchapixel — Transforming normals](https://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/geometry/transforming-normals.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Z-Fighting > Z-fighting is the shimmering stripes you get when two surfaces are closer than the depth buffer can resolve. Fix the geometry, the near plane or the bias. - Category: Three.js & Rendering › Rendering - Level: core - Tags: depth buffer, precision, polygon offset, near plane, decals - Updated: 2026-11-01 - URL: https://gdcs.coody.app/threejs/rendering/z-fighting ## What it is The depth buffer stores one value per pixel, usually 24 bits. When two triangles land on the same pixel with depths that round to the same value, which one wins depends on tiny interpolation differences, so the winner changes from pixel to pixel and frame to frame. That noisy, crawling pattern is **z-fighting**. Perspective depth is **not linear**: it is proportional to `1/z`, so most of the precision sits right in front of the near plane and very little is left far away. The depth step grows with the square of distance and shrinks as you push the near plane out. Going from `near = 0.01` to `near = 0.1` buys ten times more precision everywhere; moving the far plane barely matters. Fixes, in order of preference: don't author coplanar surfaces (cut the hole, or lift decals by a few millimetres); raise `camera.near` as far as the game allows; use `material.polygonOffset` to bias one surface toward the camera; and for huge worlds, a **logarithmic** or **reversed-Z** float depth buffer (`logarithmicDepthBuffer: true` or `reverseDepthBuffer: true` on the renderer). Nearby, related artefacts share the same cause: shadow acne is z-fighting between a surface and its own shadow map, cured with a depth bias, and flickering outlines on [billboards](https://gdcs.coody.app/threejs/rendering/billboarding) or decals often are too. ## Key formulas - **Stored depth (perspective):** $z_{buf} = \frac{f}{f - n}\left(1 - \frac{n}{z}\right)$ — 0 at the near plane, 1 at the far plane; hyperbolic in z. - **Smallest resolvable gap:** $\Delta z \approx \frac{z^2}{n \cdot 2^{b}} \quad (f \gg n)$ — b = depth bits (24). Doubling n halves Δz; doubling z quadruples it. - **Polygon offset:** $z' = z + \text{factor}\cdot\max\left|\tfrac{\partial z}{\partial x}, \tfrac{\partial z}{\partial y}\right| + \text{units}\cdot r$ — r is the smallest resolvable depth step. Negative values pull toward the camera. ## Rules of thumb - Pick `near` as large as possible: 0.1 for first-person, 0.5–1 for third-person or strategy cameras. - Keep far/near under about 10,000 with a standard 24-bit buffer. - Decals: lift 0.001–0.01 world units or use `polygonOffsetFactor: -1, polygonOffsetUnits: -4`. - Never place two meshes with exactly coincident faces; boolean them or delete the hidden face. - Planet-scale or space scenes: use a logarithmic or reversed-Z depth buffer. ## Implementation Decal over a floor without fighting: ```ts const camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000) // not 0.0001! const decalMaterial = new THREE.MeshStandardMaterial({ map: decalTexture, transparent: true, depthWrite: false, // decals shouldn't occlude each other polygonOffset: true, polygonOffsetFactor: -1, // scales with the surface slope polygonOffsetUnits: -4, // constant bias in depth steps }) // Large worlds: trade a little speed for far more depth precision. const renderer = new THREE.WebGLRenderer({ logarithmicDepthBuffer: true }) ``` ## Common mistakes - Setting `near` to 0.0001 "to be safe" and destroying precision for the whole scene. - Raising `far` to fix fighting; it barely helps. The near plane is what matters. - Using a fixed tiny offset for decals at all distances; far away it is below one depth step and fights again. - Huge `polygonOffset` values that make decals show through walls. - Coincident faces from modular kits (floors overlapping at seams). ## Interactive visual A cyan floor and an orange panel share the same plane as they turn. With no fix the depth test flips between them, producing stripes. Choose polygon offset or a small lift to settle it, or open a tiny gap and slide the near plane: a very small near brings the fighting back even with a gap. ## Prompt ```text In a TypeScript Three.js project, add a `DecalLayer` helper that creates a decal mesh over a surface without z-fighting. Use a material with polygonOffset (factor -1, units -4), depthWrite false and transparent true, and also lift the mesh along the surface normal by an amount that grows with camera distance (e.g. 0.0005 × distance, clamped to 0.02) updated each frame without allocations. Expose a `depthStep(z, near, bits = 24)` utility returning z² / (near × 2^bits) and log a warning on startup if the camera's near is below 0.05. Write a Vitest test for depthStep (doubling near halves it; doubling z quadruples it). ``` ## Related - [Surface Normals](https://gdcs.coody.app/threejs/rendering/normals.md) — A normal is a unit vector perpendicular to a surface. Lighting, slope checks, bounces and decals all start from it, so it must be right. - [Frustum Culling](https://gdcs.coody.app/threejs/rendering/frustum-culling.md) — Frustum culling skips objects whose bounding volume lies fully outside the camera's six view planes, before any draw call is made. - [Fragment Shaders](https://gdcs.coody.app/threejs/rendering/fragment-shader.md) — A fragment shader computes the colour of each pixel a triangle covers. Feed it UVs, normals and a time uniform and it paints anything. ## Further reading - [Three.js docs — Material.polygonOffset](https://threejs.org/docs/#api/en/materials/Material.polygonOffset) - [Khronos OpenGL wiki — Depth Buffer Precision](https://www.khronos.org/opengl/wiki/Depth_Buffer_Precision) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Quaternions > A unit quaternion stores a 3D rotation as an axis and half-angle. It composes cheaply, never gimbal-locks and slerps along the shortest arc. - Category: Mathematics › Rotations - Level: core - Tags: quaternion, slerp, rotation, euler angles, gimbal lock - Updated: 2026-11-01 - URL: https://gdcs.coody.app/mathematics/rotations/quaternions ## What it is A rotation by angle `θ` around unit axis `â` is the quaternion `q = (â·sin(θ/2), cos(θ/2))`, four numbers `x, y, z, w` with length 1. Rotating a vector is `q v q⁻¹`, and applying rotation `a` then `b` is the product `b * a`. That's it: no trigonometry per frame, no matrix orthonormalization, no singular poses. **Euler angles** (pitch, yaw, roll) are fine for editing and for a first-person camera, but they are three rotations in a fixed order. When the middle one hits ±90° two axes line up and you lose a degree of freedom (**gimbal lock**). Interpolating them component-wise also goes the wrong way across the ±180° seam and wobbles through odd intermediate poses. **Slerp** (spherical linear interpolation) moves along the shortest great-circle arc between two orientations at constant angular speed, which is what turrets, cameras and animation blending want. `q` and `-q` describe the same rotation, so a correct slerp flips the sign of one input when their dot product is negative; otherwise it takes the 360°-minus-angle long way. In Three.js every `Object3D` keeps `quaternion` and `rotation` (Euler) in sync. Use `quaternion.slerp`, `rotateTowards` or `setFromUnitVectors` for gameplay, and keep Euler for UI sliders. See [easing](https://gdcs.coody.app/mathematics/interpolation/easing) to shape the `t` you feed slerp. ## Key formulas - **Axis–angle:** $q = \left(\hat{a}\sin\tfrac{\theta}{2},\; \cos\tfrac{\theta}{2}\right), \quad \lVert q\rVert = 1$ - **Rotate a vector:** $v' = q\,v\,q^{*}, \quad q^{*} = (-x, -y, -z, w)$ - **Slerp:** $\operatorname{slerp}(a, b, t) = \frac{\sin((1-t)\Omega)}{\sin\Omega}a + \frac{\sin(t\Omega)}{\sin\Omega}b,\quad \cos\Omega = a\cdot b$ — If a·b < 0, negate b first. If Ω ≈ 0, normalized lerp is accurate and cheaper. - **Angle between orientations:** $\theta = 2\arccos\lvert a \cdot b\rvert$ ## Rules of thumb - Order matters: `parent * child`, and `q2 * q1` means q1 first, then q2. - Re-normalize after many multiplications (every frame for integrated rotations is cheap). - Smooth follow: `q.slerp(target, 1 - Math.exp(-k * dt))` with k ≈ 5–15 is frame-rate independent. - Max turn rate: `q.rotateTowards(target, maxRadPerSec * dt)`. - Use nlerp (lerp + normalize) for small angles or many blends; it is close to slerp under ~30°. ## Implementation Turret that turns toward a target at a capped speed: ```ts const look = new THREE.Matrix4() const targetQ = new THREE.Quaternion() const UP = new THREE.Vector3(0, 1, 0) function aimTurret(turret: THREE.Object3D, targetPos: THREE.Vector3, dt: number) { // Orientation that faces the target (Object3D looks down +Z here). look.lookAt(targetPos, turret.position, UP) targetQ.setFromRotationMatrix(look) turret.quaternion.rotateTowards(targetQ, THREE.MathUtils.degToRad(120) * dt) } // Blend two poses halfway; slerp already takes the shortest arc. const mid = new THREE.Quaternion().slerpQuaternions(poseA, poseB, 0.5) // Rotate a vector by 90° around Y. const q = new THREE.Quaternion().setFromAxisAngle(UP, Math.PI / 2) const v = new THREE.Vector3(1, 0, 0).applyQuaternion(q) // ≈ (0, 0, -1) ``` ## Common mistakes - Lerping Euler angles between 170° and -170°: the object spins 340° the wrong way. - Writing your own slerp without the `dot < 0` sign flip. - Editing `x, y, z, w` by hand as if they were angles; they are sines and cosines of half-angles. - Multiplying in the wrong order and rotating around world axes instead of local ones. - Letting a quaternion drift away from unit length after thousands of incremental updates. ## Interactive visual Two arrows turn from the same start (grey dot) to the same end (green dot). The orange one lerps Euler angles, the cyan one slerps quaternions; each leaves a trail of where its nose went. The default crosses the ±180° yaw seam, so Euler takes the long way. Untick shortest arc to make slerp do the same. ## Prompt ```text Write a TypeScript module of quaternion helpers on plain [x, y, z, w] tuples (no Three.js dependency): fromAxisAngle, fromEuler (XYZ order, matching Three.js), multiply, conjugate, rotateVector, normalize, angleBetween (2·acos|a·b|), slerp(a, b, t, shortest = true) that flips b when a·b < 0 and falls back to normalized lerp when a·b > 0.9995, and rotateTowards(a, b, maxAngle). Add Vitest tests: fromEuler equals THREE.Quaternion.setFromEuler for random angles; slerp returns unit quaternions and hits both endpoints; slerp at t = 0.5 is half the angle; slerping yaw 170° → -170° sweeps 20° while lerping the Euler angles sweeps 340°. ``` ## Related - [Easing Functions](https://gdcs.coody.app/mathematics/interpolation/easing.md) — An easing function remaps progress t from 0..1 so motion accelerates, decelerates, overshoots or bounces instead of moving at a robotic constant speed. - [Billboarding](https://gdcs.coody.app/threejs/rendering/billboarding.md) — A billboard is a flat quad rotated every frame to face the camera, so a 2D image reads as 3D. Use full facing for sprites, Y-axis only for trees. - [Steering Behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors.md) — Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. ## Further reading - [Three.js docs — Quaternion](https://threejs.org/docs/#api/en/math/Quaternion) - [Scratchapixel — Geometry: rotations and transforms](https://www.scratchapixel.com/lessons/mathematics-physics-for-computer-graphics/geometry/how-does-matrix-work-part-1.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Easing Functions > An easing function remaps progress t from 0..1 so motion accelerates, decelerates, overshoots or bounces instead of moving at a robotic constant speed. - Category: Mathematics › Interpolation - Level: core - Tags: easing, tween, interpolation, animation, lerp - Updated: 2026-11-01 - URL: https://gdcs.coody.app/mathematics/interpolation/easing ## What it is A tween moves a value from `a` to `b` over a duration. Linear progress `t = elapsed / duration` gives constant speed, which reads as mechanical. An **easing function** `f(t)` reshapes that progress before the lerp: `value = a + (b - a) · f(t)`. Every easing satisfies `f(0) = 0` and `f(1) = 1`; what happens in between is the personality. **Ease-in** (`t²`, `t³`) starts slow and ends fast, like something gathering speed: good for objects leaving the screen. **Ease-out** (`1 - (1 - t)²`) starts fast and settles, like something with friction arriving: the default for UI and cameras. **Ease-in-out** does both and suits moves that start and end at rest. **Back** overshoots and returns, **elastic** wobbles, **bounce** imitates a dropped ball. The slope of the curve is the speed. Zero slope at an end means a soft start or stop; a vertical jump in slope is a visible jolt. Any ease-in turns into its ease-out mirror with `out(t) = 1 - in(1 - t)`. Easing is for **scripted** motion with a known duration: menus, pickups, camera cuts, [squash and stretch](https://gdcs.coody.app/game-feel/motion/squash-and-stretch). For motion that chases a moving target, use a damped approach (`lerp` with `1 - exp(-k·dt)`) or a spring instead, so it stays frame-rate independent. ## Key formulas - **Eased lerp:** $x(t) = a + (b - a)\,f\!\left(\tfrac{t_{now} - t_0}{T}\right)$ - **Common curves:** $\text{in}_2 = t^2,\quad \text{out}_2 = 1-(1-t)^2,\quad \text{smoothstep} = 3t^2 - 2t^3$ - **Mirror:** $f_{out}(t) = 1 - f_{in}(1 - t)$ - **Back (overshoot):** $f(t) = 1 + (s+1)(t-1)^3 + s(t-1)^2,\quad s \approx 1.70158$ — s = 1.70158 overshoots by about 10%. Bigger s, bigger overshoot. ## Rules of thumb - UI pops in: ease-out cubic, 150–250 ms. Out of the screen: ease-in, slightly faster. - Camera moves between points: ease-in-out, 0.4–1.0 s. - Pickup / reward: ease-out-back for a satisfying overshoot. - Clamp t to [0, 1] before easing; back and elastic already go outside [0, 1] on their own. - Keep easing functions pure `(t) => number` so they can be swapped and unit tested. ## Implementation A tiny tween with pluggable easing: ```ts type Easing = (t: number) => number const outCubic: Easing = (t) => 1 - (1 - t) ** 3 const inOutQuad: Easing = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2) const outBack: Easing = (t) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2 function tween(from: THREE.Vector3, to: THREE.Vector3, seconds: number, ease: Easing) { let elapsed = 0 const out = new THREE.Vector3() return (dt: number) => { elapsed = Math.min(elapsed + dt, seconds) out.lerpVectors(from, to, ease(elapsed / seconds)) return { value: out, done: elapsed >= seconds } } } const step = tween(card.position.clone(), new THREE.Vector3(0, 2, 0), 0.25, outBack) // in the frame loop: card.position.copy(step(dt).value) ``` ## Common mistakes - Easing a value toward a moving target every frame; it never arrives and changes speed with frame rate. - Applying the easing to elapsed time instead of normalized progress (forgetting to divide by duration). - Not clamping t, so a long frame pushes the value past the end (especially with ease-in curves). - Using bounce or elastic for everything; they are seasoning, not the base. - Restarting a tween from its original start instead of the current value when interrupted, causing a snap. ## Interactive visual The cyan curve is the chosen easing plotted as progress (left to right) against value (up), with linear as the grey diagonal. The orange dot rides the curve, and the orange ball on the track below moves by that value while the grey ball moves linearly. Switch curves to compare how they start, stop and overshoot. ## Prompt ```text Create `src/lib/easing.ts` in TypeScript with pure functions `(t: number) => number` for linear, in/out/inOut quad and cubic, inOutSine, outExpo, outBack (s = 1.70158), outElastic, outBounce and smoothstep, plus `reverse(f)` returning 1 - f(1 - t) and `ease(f, a, b, t)` that clamps t to [0, 1]. Then write a `Tween` class for Three.js that interpolates a Vector3 over a duration with a chosen easing, supports interruption by restarting from the current value, and never allocates in update(dt). Write Vitest tests: every easing has f(0) = 0 and f(1) = 1; in-out curves pass through 0.5 at t = 0.5; quad curves are monotonic; outBack exceeds 1 somewhere. ``` ## Related - [Quaternions](https://gdcs.coody.app/mathematics/rotations/quaternions.md) — A unit quaternion stores a 3D rotation as an axis and half-angle. It composes cheaply, never gimbal-locks and slerps along the shortest arc. - [Squash and Stretch](https://gdcs.coody.app/game-feel/motion/squash-and-stretch.md) — Stretch an object along its velocity and squash it on impact, scaling the other axes so volume stays constant. Motion reads as fast and elastic. - [Acceleration and Friction](https://gdcs.coody.app/game-feel/motion/acceleration-and-friction.md) — Ramp velocity toward input × max speed with an acceleration, and back to zero with a separate deceleration, to give movement weight. ## Further reading - [Three.js docs — MathUtils (lerp, damp, smoothstep)](https://threejs.org/docs/#api/en/math/MathUtils) - [MDN — CSS easing functions](https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Seeded Noise > A seeded PRNG plus gradient noise and fBm turns one integer into an endless, smooth, repeatable world: same seed, same terrain on every machine. - Category: Mathematics › Procedural - Level: common - Tags: procedural, noise, perlin, fbm, prng, seed - Updated: 2026-11-01 - URL: https://gdcs.coody.app/mathematics/procedural/seeded-noise ## What it is `Math.random()` can't be seeded, so a level generated with it can never be rebuilt. A **seeded PRNG** such as mulberry32 or xorshift is a tiny pure function of its state: give it the same 32-bit seed and it returns the same sequence forever. Hash strings like `"world-7/chunk-3,-2"` into seeds so every chunk, loot table or enemy wave has its own reproducible stream. Random numbers alone are white noise: neighbouring values are unrelated, which looks like TV static. **Gradient noise** (Perlin-style) puts a pseudo-random unit gradient at each integer lattice point and smoothly blends their contributions with a quintic fade curve. The result is continuous, band-limited, zero on the lattice, and the gradients come from a permutation table shuffled by the seed. One layer of noise gives smooth blobs of a single size. **Fractal Brownian motion (fBm)** sums several **octaves**, each with double the frequency (`lacunarity ≈ 2`) and half the amplitude (`gain ≈ 0.5`): big continents, then hills, then bumps. Divide by the summed amplitudes to keep the output in about [-1, 1], then shape it (power curves, terraces, ridges with `1 - |n|`). Determinism has rules: never mix in `Math.random()`, frame time or iteration order of unordered maps, and keep the algorithm version with the save. Stream chunks independently by seeding per chunk and sampling noise in **world** coordinates so seams match ([chunk streaming](https://gdcs.coody.app/performance/world/chunk-streaming)). ## Key formulas - **Gradient noise in a cell:** $n(\mathbf{p}) = \operatorname{lerp}_{v}\big(\operatorname{lerp}_{u}(g_{00}\!\cdot\!\mathbf{d}_{00},\, g_{10}\!\cdot\!\mathbf{d}_{10}),\, \operatorname{lerp}_{u}(g_{01}\!\cdot\!\mathbf{d}_{01},\, g_{11}\!\cdot\!\mathbf{d}_{11})\big)$ — g = corner gradient, d = vector from that corner to p; u, v are the faded fractional coordinates. - **Quintic fade:** $\varphi(t) = 6t^5 - 15t^4 + 10t^3$ - **fBm:** $F(\mathbf{p}) = \frac{\sum_{i=0}^{k-1} g^{i}\, n(\ell^{i}\mathbf{p})}{\sum_{i=0}^{k-1} g^{i}}$ — k octaves, lacunarity ℓ ≈ 2, gain g ≈ 0.5. ## Rules of thumb - 4–6 octaves for terrain; each extra octave doubles detail and adds one noise call per sample. - Stop adding octaves once the finest wavelength is under a vertex or pixel spacing. - Offset the input (e.g. +1000.5) instead of re-seeding to get a second independent field cheaply. - Store seeds as unsigned 32-bit ints; hash strings with FNV-1a or similar. - Use separate PRNG streams per system (terrain, loot, AI) so changing one doesn't reshuffle the others. ## Implementation Seeded PRNG and fBm heights for a terrain mesh: ```ts function mulberry32(seed: number) { let a = seed >>> 0 return () => { a = (a + 0x6d2b79f5) >>> 0 let t = Math.imul(a ^ (a >>> 15), a | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } // createNoise2D(seed) builds a seeded permutation table + gradients. const noise = createNoise2D(seed) const pos = terrain.geometry.attributes.position for (let i = 0; i < pos.count; i++) { const x = pos.getX(i), z = pos.getZ(i) let h = 0, amp = 1, freq = 0.08, norm = 0 for (let o = 0; o < octaves; o++) { h += amp * noise(x * freq, z * freq) norm += amp amp *= 0.5 freq *= 2 } pos.setY(i, (h / norm) * heightScale) } pos.needsUpdate = true terrain.geometry.computeVertexNormals() ``` ## Common mistakes - Calling `Math.random()` anywhere in generation, which silently breaks reproducibility. - Sampling noise in chunk-local coordinates, so neighbouring chunks don't line up. - Forgetting to normalize fBm, so adding octaves keeps raising the peak height. - Re-seeding a PRNG with consecutive seeds and expecting unrelated output from a weak generator; hash the seed first. - Sampling exactly on integer coordinates, where gradient noise is always 0. ## Interactive visual A heightfield built from fBm gradient noise: cyan is water, green lowland, grey rock and white peaks. Move the seed and the land changes; move it back and the exact same terrain returns. Octaves add finer detail on top of the same large shapes, and the readout shows the first PRNG values for the current seed. ## Prompt ```text Write `src/lib/noise.ts` in TypeScript: `mulberry32(seed)` returning floats in [0, 1); `hashSeed(text)` using FNV-1a; `createNoise2D(seed)` implementing 2D gradient noise with a 256-entry permutation table shuffled by mulberry32 (Fisher–Yates), 16 unit gradients and a quintic fade, scaled to roughly [-1, 1]; and `fbm2D(noise, x, y, { octaves, lacunarity, gain })` normalized by the sum of amplitudes. Then build a Three.js heightfield (PlaneGeometry 128×128) from fBm sampled in world coordinates, coloured by height. Vitest: the same seed yields identical sequences and noise values, different seeds differ, noise and fBm stay within [-1, 1], and noise is 0 at integer lattice points. ``` ## Related - [Chunk Streaming](https://gdcs.coody.app/performance/world/chunk-streaming.md) — Split a big world into chunks, load those within a radius of the player and unload them past a larger radius, so the boundary never thrashes. - [Easing Functions](https://gdcs.coody.app/mathematics/interpolation/easing.md) — An easing function remaps progress t from 0..1 so motion accelerates, decelerates, overshoots or bounces instead of moving at a robotic constant speed. - [Surface Normals](https://gdcs.coody.app/threejs/rendering/normals.md) — A normal is a unit vector perpendicular to a surface. Lighting, slope checks, bounces and decals all start from it, so it must be right. ## Further reading - [Red Blob Games — Making maps with noise functions](https://www.redblobgames.com/maps/terrain-from-noise/) - [Scratchapixel — Perlin noise](https://www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/perlin-noise-part-2/perlin-noise.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Coyote Time > Coyote time keeps a jump available for ~0.1 s after the character walks off an edge, so presses that feel on time are not eaten by the game. - Category: Game Feel › Timing - Level: common - Tags: coyote time, jump, platformer, grace period, input forgiveness - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/timing/coyote-time ## What it is Players judge a ledge by eye, and the button press reaches the game a few frames after they decide. A strict platformer only allows a jump while `grounded` is true, so a press that lands one or two frames after the feet leave the edge does nothing and the character drops. To the player it feels like the game ignored the input. Coyote time fixes this with a timer. While grounded, keep it full (e.g. `0.1` s). Once airborne, count it down with [delta time](https://gdcs.coody.app/game-feel/timing/delta-time). A jump is allowed while `grounded || coyoteTimer > 0`. When the jump fires, set the timer to zero so it cannot be used twice. It is invisible when tuned well: the jump simply always seems to work at the edge. It pairs naturally with [input buffering](https://gdcs.coody.app/game-feel/timing/input-buffering), which forgives presses that come slightly **too early** instead of too late. ## Key formulas - **Jump allowed:** $\text{canJump} = \text{grounded} \;\lor\; (t - t_{\text{left ground}}) < T_{\text{coyote}}$ - **Extra ledge distance:** $d = v_{run}\,T_{\text{coyote}}$ — At 6 u/s and 0.1 s the character can be 0.6 units past the edge and still jump. ## Rules of thumb - Start with 0.08–0.15 s (about 5–9 frames at 60 fps). Over ~0.2 s it starts to feel like a mid-air jump. - Only start the timer when the character walks off. Jumping should zero it, or the player gets a free second jump. - Measure in seconds, not frames, so the window is the same at every frame rate. - Many games also ignore gravity or cap the fall speed during the window so the jump still starts from near ledge height. ## Implementation Coyote timer inside a character update: ```ts const COYOTE = 0.1 // seconds let coyoteTimer = 0 function update(dt: number, jumpPressed: boolean) { if (player.grounded) coyoteTimer = COYOTE else coyoteTimer = Math.max(0, coyoteTimer - dt) if (jumpPressed && (player.grounded || coyoteTimer > 0)) { player.velocity.y = JUMP_SPEED coyoteTimer = 0 // consume it: no double jump player.grounded = false } player.velocity.y -= GRAVITY * dt player.position.addScaledVector(player.velocity, dt) } ``` ## Common mistakes - Not clearing the timer on jump, which lets players jump again during the first frames of a jump. - Counting frames instead of seconds, so the window shrinks to 40 ms on a 144 Hz monitor. - Refilling the timer when the character touches walls or ceilings, which turns it into a wall-jump exploit. - Making the window so long that walking off a cliff and then jumping looks like a double jump. ## Interactive visual The cube runs off the ledge and the player presses jump at the yellow line, a little late. The green strip is the grace window. Runs alternate between coyote time on (green, the jump works) and off (grey, it falls into the red pit). Slide the press later than the window to see it fail even with coyote time. ## Prompt ```text Add coyote time to my TypeScript Three.js platformer controller. Keep a coyoteTimer in seconds: refill it to 0.1 s every update the character is grounded, and count it down by dt while airborne. Allow a jump when grounded or coyoteTimer > 0; when the jump fires, set the timer to 0 and set vertical velocity to JUMP_SPEED. Do not refill on wall or ceiling contact. Expose COYOTE as a tunable constant. Write unit tests that step the controller at 30 and 144 fps: a press 0.05 s after walking off a ledge jumps, a press 0.15 s after does not, and pressing twice never yields two jumps. ``` ## Related - [Input Buffering](https://gdcs.coody.app/game-feel/timing/input-buffering.md) — Input buffering stores a press for ~0.1 s and performs it as soon as the action becomes possible, so slightly early inputs still count. - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. - [Finite State Machine](https://gdcs.coody.app/ai/decision/finite-state-machine.md) — An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. ## Further reading - [GDC Vault — Math for Game Programmers: Building a Better Jump](https://www.gdcvault.com/play/1023559/Math-for-Game-Programmers-Building) - [Game Programming Patterns — State](https://gameprogrammingpatterns.com/state.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Delta Time > Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. - Category: Game Feel › Timing - Level: core - Tags: delta time, frame rate, game loop, frame-rate independence, requestAnimationFrame - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/timing/delta-time ## What it is Frames do not arrive at a steady rate. A 144 Hz monitor runs the loop more than twice as often as a 60 Hz one, and a busy frame can take 40 ms instead of 16. If you move a player by `5` units **per frame**, their speed depends on the hardware. Delta time `dt` is the real time the previous frame took. Write speeds in units **per second** and multiply by `dt`, and motion becomes frame-rate independent. In the browser, `requestAnimationFrame` hands your callback a timestamp in milliseconds. `dt` is that timestamp minus the previous one, divided by 1000. In React Three Fiber the second argument of `useFrame` is already `dt` in seconds. Linear changes (`x += v·dt`) scale exactly. Anything that compounds per frame does not: `v *= 0.9` each frame is friction that is stronger at high fps. Use `Math.pow(0.9, dt·60)` or the exponential form `1 − e^(−k·dt)` instead. For physics or anything that must be deterministic, accumulate `dt` and step a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep). ## Key formulas - **Position update:** $x_{t+\Delta t} = x_t + v\,\Delta t$ - **Frame-rate independent decay:** $v \leftarrow v \cdot f^{\,\Delta t \cdot 60} = v\,e^{-k\,\Delta t}$ — f is the per-frame factor you tuned at 60 fps; k = −60·ln f is the same decay per second. - **Exponential smoothing toward a target:** $x \leftarrow x + (x_{target} - x)\,(1 - e^{-k\,\Delta t})$ ## Rules of thumb - Clamp `dt` to about 0.1 s (or 0.25 s). A tab that was in the background can report several seconds and teleport everything. - Store speeds in units/s, accelerations in units/s², and rates as per-second constants. Never tune in "per frame" units. - Pause by setting `dt = 0`; slow motion by `dt × timeScale`. Keep a separate unscaled `dt` for UI and menus. - Gravity with semi-implicit Euler (`v += g·dt; x += v·dt`) is fine for gameplay. Use a fixed step if jump height must be identical everywhere. ## Implementation A minimal browser loop with clamped delta time: ```ts let last = performance.now() const speed = 4 // units per second function frame(now: number) { const dt = Math.min((now - last) / 1000, 0.1) // seconds, clamped last = now player.position.x += speed * dt // per-frame friction 0.9 tuned at 60 fps, made fps-independent velocity *= Math.pow(0.9, dt * 60) renderer.render(scene, camera) requestAnimationFrame(frame) } requestAnimationFrame(frame) ``` ## Common mistakes - Moving by a constant amount per frame. The game runs 2.4× faster on a 144 Hz display than on 60 Hz. - Multiplying a per-frame damping factor without converting it: `v *= 0.98` is much stronger at high frame rates. - Not clamping `dt` after a pause, breakpoint or hidden tab, so objects tunnel through walls on the next frame. - Applying `dt` twice, e.g. to the velocity and again to an acceleration that was already integrated. - Expecting variable `dt` to give deterministic physics. Replays and networking need a fixed step. ## Interactive visual Two runners race under a simulated frame rate. Cyan multiplies its speed by delta time; red moves a fixed amount per frame tuned for 60 fps. The tick marks are individual frames. Drop the FPS slider and red falls behind; raise it above 60 and red wins. Turn on uneven frames and cyan still arrives on time. ## Prompt ```text Write a TypeScript game loop for a Three.js scene using requestAnimationFrame. Compute dt in seconds from the rAF timestamp, clamp it to 0.1 s, and support a timeScale (0 = paused) while also passing an unscaled dt to UI code. Move a player at a speed in units/s, apply friction as v *= Math.pow(f, dt * 60), and smooth a camera with x += (target - x) * (1 - Math.exp(-k * dt)). Put the pure update in a function and write a Vitest test that runs it at 30 and 144 fps for 2 s and asserts positions match within 1%. ``` ## Related - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Frame Budget](https://gdcs.coody.app/performance/cpu/frame-budget.md) — At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. - [Acceleration and Friction](https://gdcs.coody.app/game-feel/motion/acceleration-and-friction.md) — Ramp velocity toward input × max speed with an acceleration, and back to zero with a separate deceleration, to give movement weight. - [Hit Stop](https://gdcs.coody.app/game-feel/timing/hit-stop.md) — Hit stop pauses the attacker and target for a few frames on impact. The brief freeze sells weight and gives the eye time to read the hit. ## Further reading - [Gaffer On Games — Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/) - [MDN — Window.requestAnimationFrame()](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) - [Game Programming Patterns — Game Loop](https://gameprogrammingpatterns.com/game-loop.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Hit Stop > Hit stop pauses the attacker and target for a few frames on impact. The brief freeze sells weight and gives the eye time to read the hit. - Category: Game Feel › Timing - Level: common - Tags: hit stop, hit lag, freeze frame, impact, combat feel - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/timing/hit-stop ## What it is When a hit connects, many action and fighting games stop the attacker and the target for a short moment, typically 3–10 frames, before the knockback plays. The pause reads as resistance, like a blade biting into something solid, and makes heavy attacks feel heavier than light ones even when their animations are similar. Implement it as a timer that zeroes the **game** delta time for the frozen objects. Freeze only the participants and let effects (sparks, screen flash, [screen shake](https://gdcs.coody.app/game-feel/camera/screen-shake)) keep running so the moment stays alive. A small jitter on the target during the freeze adds to the effect. Scale the duration with the hit: light jab ~50 ms, heavy strike ~120–200 ms, finishing blow longer. Because inputs keep arriving during the freeze, pair it with an [input buffer](https://gdcs.coody.app/game-feel/timing/input-buffering) so combo presses made during the stop are not lost. ## Key formulas - **Game time during hit stop:** $\Delta t_{game} = \begin{cases} 0 & t_{stop} > 0 \\ \Delta t \cdot s_{time} & \text{otherwise} \end{cases}$ - **Duration from damage:** $T_{stop} = \min(T_{max},\; T_{base} + k \cdot \text{damage})$ ## Rules of thumb - Start at 60–100 ms for normal hits, up to ~200 ms for heavy or critical hits. Past ~300 ms it reads as lag. - Count the timer down with real (unscaled) `dt`, not the game `dt` it is freezing. - Do not stack durations from several simultaneous hits. Take the max of the remaining time and the new one. - Keep UI, audio and particles on unscaled time so the world does not feel frozen. ## Implementation Per-entity hit stop that does not lose leftover time: ```ts class Fighter { hitStop = 0 // seconds of freeze left onHit(duration: number) { this.hitStop = Math.max(this.hitStop, duration) } update(realDt: number) { let dt = realDt if (this.hitStop > 0) { const used = Math.min(this.hitStop, realDt) this.hitStop -= used dt = realDt - used // 0 while frozen, leftover on the last frame } this.animation.update(dt) this.body.integrate(dt) } } ``` ## Common mistakes - Freezing the whole game loop, including input and audio, so the pause feels like a frame drop. - Counting the freeze in frames, which halves its length on a 120 Hz display. - Using the same duration for every attack, so light and heavy hits feel the same. - Letting hit stop and knockback both run, so the target slides during the freeze. ## Interactive visual Both rows play the same attack on the same clock. The top row (green marker) freezes cyan attacker and target for the hit-stop duration, with the target flashing orange and jittering; the bottom row has no hit stop and the blow slides past. The thin bar under each row is game time, so the top one visibly lags. Lower the playback speed to count the frozen frames. ## Prompt ```text Add hit stop to my TypeScript Three.js brawler. Each fighter has hitStop seconds; on a hit, both attacker and target get hitStop = max(current, 0.06 + 0.004 * damage), capped at 0.2 s. In update(realDt), consume hitStop from real time and pass the remaining game dt (0 while frozen, the leftover on the last frozen frame) to animation and physics. While frozen, jitter the target's mesh ±0.05 units on x without moving its physics body, and keep particles and UI on unscaled time. Write a Vitest test that at 30 and 144 fps a 0.12 s hit stop removes exactly 0.12 s of game time. ``` ## Related - [Screen Shake](https://gdcs.coody.app/game-feel/camera/screen-shake.md) — Trauma-based screen shake: hits add trauma (0–1) that decays over time, and the camera is offset by trauma² × smooth noise for impact. - [Invincibility Frames](https://gdcs.coody.app/game-feel/timing/invincibility-frames.md) — Invincibility frames (i-frames) make a character ignore damage for a short window after a hit or during a dodge, with blinking to show it. - [Hitboxes & Hurtboxes](https://gdcs.coody.app/systems/collision/hitbox-hurtbox.md) — Attacks own hitboxes that exist only during active frames; characters own hurtboxes. An overlap is a hit, registered once per swing per target. - [Input Buffering](https://gdcs.coody.app/game-feel/timing/input-buffering.md) — Input buffering stores a press for ~0.1 s and performs it as soon as the action becomes possible, so slightly early inputs still count. ## Further reading - [GDC Vault — Math for Game Programmers: Juicing Your Cameras With Math](https://www.gdcvault.com/play/1023146/Math-for-Game-Programmers-Juicing) - [Game Programming Patterns — Game Loop](https://gameprogrammingpatterns.com/game-loop.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Input Buffering > Input buffering stores a press for ~0.1 s and performs it as soon as the action becomes possible, so slightly early inputs still count. - Category: Game Feel › Timing - Level: common - Tags: input buffer, jump buffer, combo, responsiveness, input forgiveness - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/timing/input-buffering ## What it is Players press buttons slightly **before** an action is allowed: jump a few frames before landing, attack while the previous swing is still recovering, dodge just before a stun ends. If the game only checks the button on the exact frame it is legal, those presses vanish and the controls feel sticky. An input buffer records the press with a timestamp, or a countdown timer, and keeps checking it for a short window. The first frame the action becomes legal inside that window, it fires and the buffer is cleared. Presses older than the window are discarded, so a stale press does not trigger something the player no longer wants. The jump buffer is the mirror image of [coyote time](https://gdcs.coody.app/game-feel/timing/coyote-time): coyote time forgives late presses, buffering forgives early ones. Fighting and action games extend the idea to a short queue of commands so combos chain on the first legal frame. ## Key formulas - **Buffered action fires when:** $\text{legal}(t) \;\land\; t - t_{press} \le T_{buffer}$ ## Rules of thumb - Jump buffers of 0.1–0.15 s (6–9 frames at 60 fps) feel natural. Attack and dodge buffers are often 0.15–0.3 s. - Buffer one action per type, and let a newer press overwrite an older one. - Clear the buffer when it is consumed, and on state changes such as death, pause or cutscenes. - Store time in seconds (or a timestamp), never in frames, so the window does not shrink at high refresh rates. ## Implementation Jump buffer next to a coyote timer: ```ts const BUFFER = 0.12 // seconds let bufferTimer = 0 function update(dt: number, jumpPressed: boolean) { if (jumpPressed) bufferTimer = BUFFER else bufferTimer = Math.max(0, bufferTimer - dt) const canJump = player.grounded || coyoteTimer > 0 if (bufferTimer > 0 && canJump) { player.velocity.y = JUMP_SPEED bufferTimer = 0 coyoteTimer = 0 } } ``` A tiny action buffer for combos: ```text on press(action): buffered = { action, time: now } every frame: if buffered and now - buffered.time > WINDOW: buffered = null if buffered and canPerform(buffered.action): perform(buffered.action); buffered = null ``` ## Common mistakes - Reading input in an event handler and acting on it immediately, bypassing the buffer and the state checks. - Never expiring the buffer, so a jump pressed mid-air fires a second later and feels like a glitch. - Buffering in frames, which cuts the window from 100 ms to 42 ms on a 144 Hz display. - Not clearing the buffer when it fires, causing a double jump on the next frame. ## Interactive visual The cube presses jump in mid-air at the yellow line. The strip below is a zoomed timeline: yellow is the press, white is touchdown, the cyan bar is the buffer window and the grey playhead is now. Runs alternate between buffer on (the press survives until touchdown, the cube turns green and hops again) and off (the press is dropped, it lands red and stops). Push the press earlier than the window to see it expire. ## Prompt ```text Implement a jump input buffer for a TypeScript Three.js platformer. When jump is pressed, set bufferTimer = 0.12 s; count it down by dt each update. On any update where the character can jump (grounded or coyote time left) and bufferTimer > 0, jump and clear both timers. Clear the buffer on death or pause. Read raw key presses into a per-frame `pressed` flag so the controller never acts inside event handlers. Write Vitest tests at 30, 60 and 144 fps: a press 0.08 s before landing jumps on the landing frame, a press 0.2 s before does not, and one press never gives two jumps. ``` ## Related - [Coyote Time](https://gdcs.coody.app/game-feel/timing/coyote-time.md) — Coyote time keeps a jump available for ~0.1 s after the character walks off an edge, so presses that feel on time are not eaten by the game. - [Finite State Machine](https://gdcs.coody.app/ai/decision/finite-state-machine.md) — An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. ## Further reading - [Game Programming Patterns — Command](https://gameprogrammingpatterns.com/command.html) - [MDN — KeyboardEvent](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Invincibility Frames > Invincibility frames (i-frames) make a character ignore damage for a short window after a hit or during a dodge, with blinking to show it. - Category: Game Feel › Timing - Level: core - Tags: i-frames, invulnerability, damage, dodge roll, mercy invincibility - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/timing/invincibility-frames ## What it is Without protection, a character standing in a stream of bullets or touching an enemy loses health every frame, and the player dies before they can react. **Mercy invincibility** gives a window after each hit, often 0.5–2 s, where further damage is ignored. The window gives the player time to escape and makes damage predictable: one mistake, one hit. Dodge rolls use the same mechanism on purpose. Part of the roll animation, not all of it, is invulnerable, so dodging through an attack is a timing skill. In that case the window is a range of animation frames, not a timer after damage. The window must be **visible**. The classic signal is blinking the sprite or mesh at 8–15 Hz, sometimes with a tint. Keep the hurtbox in place and let the damage check refuse the hit. Disabling collisions would also stop pickups and triggers (see [hitboxes and hurtboxes](https://gdcs.coody.app/systems/collision/hitbox-hurtbox)). ## Key formulas - **Damage accepted when:** $t - t_{\text{last hit}} \ge T_{iframes}$ - **Blink visibility:** $\text{visible} = \lfloor 2 f \, t_{\text{left}} \rfloor \bmod 2 = 0$ — f is the blink rate in Hz (8–15 is typical). ## Rules of thumb - Mercy window after damage: 0.6–1.5 s in action games, up to 2 s in platformers. - Dodge-roll i-frames: roughly 0.2–0.4 s inside a 0.5–0.8 s roll, starting a few frames in. - Blink at 8–15 Hz. Stop blinking a few frames before the window ends only if you also tell the player it is ending. - Store the window per damage source type if needed, e.g. let environmental hazards bypass it to prevent standing in lava forever. ## Implementation Health with a mercy window and blinking: ```ts const IFRAMES = 1.0 // seconds const BLINK_HZ = 10 class Health { hp = 5 invulnerable = 0 damage(amount: number) { if (this.invulnerable > 0) return false // ignored this.hp -= amount this.invulnerable = IFRAMES return true } update(dt: number, mesh: THREE.Object3D) { this.invulnerable = Math.max(0, this.invulnerable - dt) const phase = Math.floor(this.invulnerable * BLINK_HZ * 2) mesh.visible = this.invulnerable === 0 || phase % 2 === 0 } } ``` ## Common mistakes - Giving no visual cue, so players cannot tell when they are safe and the rule feels random. - Disabling the whole collider instead of ignoring damage, which also breaks pickups, triggers and enemy contact pushes. - Blinking by toggling `visible` on a parent that also hides shadows or UI attached to the character. - Frame-counted windows that last half as long at 120 fps. - Letting the player chain i-frames forever, e.g. dodge-cancelling into another dodge with no recovery. ## Interactive visual Orange shots hit the cyan player on a steady rhythm. A hit flashes red, removes a green health pip and starts the yellow i-frame bar; while it drains the player blinks and shots pass through grey. Set the window to 0 to watch health melt, or shorten the gap between shots. ## Prompt ```text Implement invincibility frames for a TypeScript Three.js action game. Create a Health component with hp and an invulnerable timer in seconds. damage(amount, source) returns false and does nothing while invulnerable (unless source.bypassIFrames is true); otherwise it subtracts hp and starts a 1.0 s window. Add a dodge that sets invulnerable for 0.3 s starting 0.05 s into a 0.6 s roll. Blink the character mesh at 10 Hz while invulnerable (toggle material opacity, not visibility, so shadows stay). Write Vitest tests: hits every frame for 1 s at 60 fps with a 0.25 s window deal exactly 4 damage, and results match at 144 fps. ``` ## Related - [Hitboxes & Hurtboxes](https://gdcs.coody.app/systems/collision/hitbox-hurtbox.md) — Attacks own hitboxes that exist only during active frames; characters own hurtboxes. An overlap is a hit, registered once per swing per target. - [Hit Stop](https://gdcs.coody.app/game-feel/timing/hit-stop.md) — Hit stop pauses the attacker and target for a few frames on impact. The brief freeze sells weight and gives the eye time to read the hit. - [Screen Shake](https://gdcs.coody.app/game-feel/camera/screen-shake.md) — Trauma-based screen shake: hits add trauma (0–1) that decays over time, and the camera is offset by trauma² × smooth noise for impact. - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. ## Further reading - [Game Programming Patterns — Update Method](https://gameprogrammingpatterns.com/update-method.html) - [Game Programming Patterns — State](https://gameprogrammingpatterns.com/state.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Acceleration and Friction > Ramp velocity toward input × max speed with an acceleration, and back to zero with a separate deceleration, to give movement weight. - Category: Game Feel › Motion - Level: core - Tags: acceleration, friction, deceleration, movement, character controller, velocity - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/motion/acceleration-and-friction ## What it is Setting `velocity = input × maxSpeed` gives instant, weightless movement. Most games instead move the **current** velocity toward that target by at most `accel × dt` per frame, and use a separate, usually higher, `decel` when the stick is released or reversed. The two numbers describe a character: a tight platformer hero reaches top speed in ~0.1 s and stops in ~0.05 s; a heavy vehicle takes a second or more. `moveTowards(v, target, rate·dt)` gives linear ramps with exact, designer-friendly times: time to top speed is `maxSpeed / accel`. The alternative is exponential damping, `v = target + (v − target)·e^(−k·dt)`. It starts fast and eases in, never quite arriving, and is frame-rate independent when written with `exp` (see [delta time](https://gdcs.coody.app/game-feel/timing/delta-time)). Use lower values in the air than on the ground (air control), a higher deceleration when turning around (skid), and surface-specific friction for ice or mud. Visual stretch while speeding up pairs well with [squash and stretch](https://gdcs.coody.app/game-feel/motion/squash-and-stretch). ## Key formulas - **Linear approach:** $v \leftarrow v + \operatorname{clamp}(v_{target} - v,\; -a\,\Delta t,\; a\,\Delta t)$ - **Exponential damping:** $v \leftarrow v_{target} + (v - v_{target})\,e^{-k\,\Delta t}$ — Choose k = a / v_max to match the linear version's initial slope. - **Time to top speed / stopping distance:** $t_{up} = \frac{v_{max}}{a}, \qquad d_{stop} = \frac{v_{max}^2}{2\,a_{decel}}$ ## Rules of thumb - Snappy platformer: reach top speed in 0.05–0.15 s, stop in 0.03–0.1 s. - Weighty character or vehicle: 0.3–1 s to top speed, deceleration 1.5–3× acceleration. - Air control: 40–70% of ground acceleration. - Turning around: use the deceleration (or a dedicated turn rate) until velocity crosses zero, then accelerate. ## Implementation Horizontal movement with separate acceleration and deceleration: ```ts function moveTowards(cur: number, target: number, maxDelta: number) { const d = target - cur return Math.abs(d) <= maxDelta ? target : cur + Math.sign(d) * maxDelta } const ACCEL = 40, DECEL = 70, MAX_SPEED = 6 // units/s², units/s const AIR_FACTOR = 0.6 function updateMovement(dt: number, input: number /* -1..1 */) { const target = input * MAX_SPEED const braking = input === 0 || Math.sign(input) !== Math.sign(vel.x) let rate = braking ? DECEL : ACCEL if (!grounded) rate *= AIR_FACTOR vel.x = moveTowards(vel.x, target, rate * dt) player.position.x += vel.x * dt } ``` ## Common mistakes - Applying friction as `v *= 0.9` per frame, which depends on frame rate. Use `moveTowards` with `dt` or `exp(−k·dt)`. - Using the same value for speeding up and slowing down. Players expect to stop faster than they start. - Clamping speed after adding input force, so diagonal input or external pushes exceed the max. - Letting friction act on velocity from knockback or moving platforms the same way as input, which kills those effects instantly. ## Interactive visual The cyan box is driven by a repeating input: right, release, left, release. The plot scrolls with time: grey is the target velocity the input asks for, yellow is the actual velocity (also the arrow on the box). Raise acceleration for a snappier ramp, lower deceleration for a longer slide, or switch the model to compare linear, exponential and instant. ## Prompt ```text Write a TypeScript movement controller for a Three.js character with separate ground acceleration (40 u/s²), deceleration (70 u/s²), max speed (6 u/s) and an air control factor (0.6). Each update: compute target = input * maxSpeed; use deceleration when input is zero or opposes current velocity, acceleration otherwise; approach the target with moveTowards(v, target, rate * dt). Also provide an exponential option v = target + (v - target) * Math.exp(-k * dt). Write Vitest tests: time to top speed equals maxSpeed / accel within one step, and positions after a 1.2 s input sequence match at 30 and 144 fps within 0.15 units. ``` ## Related - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. - [Squash and Stretch](https://gdcs.coody.app/game-feel/motion/squash-and-stretch.md) — Stretch an object along its velocity and squash it on impact, scaling the other axes so volume stays constant. Motion reads as fast and elastic. - [Steering Behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors.md) — Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. - [Camera Deadzone](https://gdcs.coody.app/game-feel/camera/camera-deadzone.md) — A camera deadzone is a box around the screen centre where the target moves freely. The camera only follows when the target pushes past its edge. ## Further reading - [Gaffer On Games — Integration Basics](https://gafferongames.com/post/integration_basics/) - [GDC Vault — Math for Game Programmers: Building a Better Jump](https://www.gdcvault.com/play/1023559/Math-for-Game-Programmers-Building) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Squash and Stretch > Stretch an object along its velocity and squash it on impact, scaling the other axes so volume stays constant. Motion reads as fast and elastic. - Category: Game Feel › Motion - Level: common - Tags: squash and stretch, animation, juice, procedural animation, scale - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/motion/squash-and-stretch ## What it is Squash and stretch is one of the classic principles of animation. A fast-moving object is drawn longer along its direction of travel, and it flattens when it hits something. The eye reads this as speed, weight and elasticity, even on a plain sphere or cube. In a game it can be fully procedural. Each frame, point the object's local up axis along its velocity and scale that axis by `s = 1 + k·|v|` (clamped). Scale the two perpendicular axes by `1/√s` so the product of the three scales stays 1. **Preserving volume** is what makes it look like rubber rather than an object that grows and shrinks. On landing, switch to a squash along the contact normal (`s < 1`) and spring back over ~0.1 s. Keep the bottom of the object on the ground by offsetting its position by the scaled radius. Apply the deformation to a visual child node only, never to the collider or the physics body. ## Key formulas - **Stretch along velocity:** $s_{\parallel} = \min\!\left(s_{max},\; 1 + k\,\lVert \mathbf{v} \rVert\right)$ - **Volume preservation (3D):** $s_{\parallel}\, s_{\perp}^2 = 1 \;\Rightarrow\; s_{\perp} = \frac{1}{\sqrt{s_{\parallel}}}$ — For a 2D sprite use s⊥ = 1 / s∥ instead. ## Rules of thumb - Keep stretch under ~1.5–1.8× and impact squash above ~0.6× unless the style is very cartoony. - Pick `k` so the stretch reaches its limit at your fastest common speed (e.g. falling at terminal velocity). - Recover from a squash in 0.08–0.15 s. Ease it with a spring or `1 − e^(−k·t)` for a slight overshoot. - Deform a child mesh, not the root, so the collider and children such as weapons stay unscaled. ## Implementation Volume-preserving stretch toward the velocity in Three.js: ```ts const UP = new THREE.Vector3(0, 1, 0) const dir = new THREE.Vector3() const K = 0.05, MAX_STRETCH = 1.6 function applyStretch(visual: THREE.Object3D, velocity: THREE.Vector3) { const speed = velocity.length() if (speed < 1e-3) { visual.quaternion.identity() visual.scale.set(1, 1, 1) return } const along = Math.min(MAX_STRETCH, 1 + K * speed) const across = 1 / Math.sqrt(along) // along * across² = 1 dir.copy(velocity).divideScalar(speed) visual.quaternion.setFromUnitVectors(UP, dir) // local Y → velocity visual.scale.set(across, along, across) } ``` ## Common mistakes - Scaling only one axis, so the object visibly gains and loses mass. - Scaling the physics body or collider and getting tunneling or jitter at contacts. - Stretching in world Y regardless of direction, so a ball moving sideways looks wrong. - Snapping between stretch and squash with no recovery time, which reads as flicker rather than elasticity. ## Interactive visual The orange ball stretches along its velocity as it falls and rises, and squashes flat for a moment after each bounce. The cyan wireframe is the undeformed ball for comparison. Turn off volume preservation to see it bloat, or set the amount to 0 for a rigid ball. ## Prompt ```text Add procedural squash and stretch to a bouncing Three.js object in TypeScript. Keep physics on the root and deform only a child mesh. Each frame: if speed > 0.001, rotate the child so local +Y points along the velocity and scale it (1/sqrt(s), s, 1/sqrt(s)) with s = min(1.6, 1 + 0.05 * speed). On a ground impact, squash along the contact normal to s = 1 - 0.35 * impactSpeed / maxImpactSpeed (min 0.6) and recover to 1 over 0.12 s with an exponential ease, keeping the bottom on the floor. Write a Vitest test that the product of the three scale factors is always 1 within 1e-9. ``` ## Related - [Acceleration and Friction](https://gdcs.coody.app/game-feel/motion/acceleration-and-friction.md) — Ramp velocity toward input × max speed with an acceleration, and back to zero with a separate deceleration, to give movement weight. - [Easing Functions](https://gdcs.coody.app/mathematics/interpolation/easing.md) — An easing function remaps progress t from 0..1 so motion accelerates, decelerates, overshoots or bounces instead of moving at a robotic constant speed. - [Screen Shake](https://gdcs.coody.app/game-feel/camera/screen-shake.md) — Trauma-based screen shake: hits add trauma (0–1) that decays over time, and the camera is offset by trauma² × smooth noise for impact. - [Hit Stop](https://gdcs.coody.app/game-feel/timing/hit-stop.md) — Hit stop pauses the attacker and target for a few frames on impact. The brief freeze sells weight and gives the eye time to read the hit. ## Further reading - [Wikipedia — Squash and stretch](https://en.wikipedia.org/wiki/Squash_and_stretch) - [Wikipedia — Twelve basic principles of animation](https://en.wikipedia.org/wiki/Twelve_basic_principles_of_animation) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Camera Deadzone > A camera deadzone is a box around the screen centre where the target moves freely. The camera only follows when the target pushes past its edge. - Category: Game Feel › Camera - Level: common - Tags: camera, deadzone, follow camera, platformer camera, smoothing - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/camera/camera-deadzone ## What it is A camera locked to the player's position copies every small motion: idle bobbing, a step back and forth, the jitter of a jump. The whole screen moves with it, which is tiring to watch and can cause motion sickness. A **deadzone** (also called a camera window) is a rectangle around the camera's focus point. While the target stays inside, the camera stays still. When the target crosses an edge, the camera moves just enough to keep it on that edge. Per axis the rule is a clamp: `cam = clamp(cam, target − half, target + half)`. Combine it with exponential smoothing toward that clamped position (`1 − e^(−k·dt)`) so the camera eases into motion instead of being dragged rigidly. Platformers often use different rules per axis: a wide horizontal zone, and vertical follow that only updates when the player lands (platform snapping) so jumps do not bounce the view. Add look-ahead in the direction of motion to show more of what is coming. Keep [screen shake](https://gdcs.coody.app/game-feel/camera/screen-shake) as an offset on top of the followed position, never inside the follow state. ## Key formulas - **Deadzone clamp (per axis):** $c' = \operatorname{clamp}\!\left(c,\; p - \tfrac{w}{2},\; p + \tfrac{w}{2}\right)$ - **Smoothed follow:** $c \leftarrow c' + (c - c')\,e^{-k\,\Delta t}, \qquad t_{1/2} = \frac{\ln 2}{k}$ — t½ is how long the camera takes to close half the remaining gap. ## Rules of thumb - Horizontal deadzone: 10–25% of screen width. Vertical: smaller, or none with platform snapping. - Smoothing rate k of 4–10 /s (half-life 70–170 ms) feels responsive without lag. - Look-ahead: shift the focus 10–20% of screen width in the facing or velocity direction, smoothed. - Update the camera after the player moves, in the same frame, to avoid one-frame jitter. ## Implementation 2D deadzone follow with frame-rate independent smoothing: ```ts const ZONE = { w: 2.4, h: 1.4 } // world units const K = 6 // smoothing rate, 1/s const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x)) function updateCamera(dt: number) { const p = player.position const wantX = clamp(focus.x, p.x - ZONE.w / 2, p.x + ZONE.w / 2) const wantY = clamp(focus.y, p.y - ZONE.h / 2, p.y + ZONE.h / 2) const a = 1 - Math.exp(-K * dt) focus.x += (wantX - focus.x) * a focus.y += (wantY - focus.y) * a camera.position.set(focus.x, focus.y, camera.position.z) } ``` ## Common mistakes - Smoothing with `lerp(cam, target, 0.1)` per frame, which follows faster at higher frame rates. - Making the deadzone so large the player can reach the screen edge before the camera reacts. - Following the player in Y during every jump, so the horizon bounces constantly. - Applying shake or recoil to the follow state, so the camera drifts after every hit. ## Interactive visual Top-down view. The orange target wanders with small jitters; the cyan rectangle is what the camera sees and the inner box is the deadzone. The box is green while the target moves inside it (camera still) and turns yellow when the target pushes the camera. The trails compare the target's path (orange) with the much calmer camera path (cyan). Set the deadzone to 0 to see a hard-locked camera copy every wiggle. ## Prompt ```text Implement a 2D-style follow camera for a TypeScript Three.js side-scroller. Keep a focus point; each frame clamp it per axis so the player stays inside a deadzone of 2.4 × 1.4 world units, then ease the camera toward that clamped point with a = 1 - Math.exp(-6 * dt). Add optional look-ahead (15% of view width in the velocity direction, itself smoothed) and a platform-snap mode that only updates the vertical target when the player is grounded. Apply shake as a separate offset after the follow. Write Vitest tests: the camera does not move while the target stays inside the zone, and the follow result after 1 s matches at 30 and 144 fps within 1%. ``` ## Related - [Screen Shake](https://gdcs.coody.app/game-feel/camera/screen-shake.md) — Trauma-based screen shake: hits add trauma (0–1) that decays over time, and the camera is offset by trauma² × smooth noise for impact. - [Acceleration and Friction](https://gdcs.coody.app/game-feel/motion/acceleration-and-friction.md) — Ramp velocity toward input × max speed with an acceleration, and back to zero with a separate deceleration, to give movement weight. - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. ## Further reading - [GDC Vault — Math for Game Programmers: Juicing Your Cameras With Math](https://www.gdcvault.com/play/1023146/Math-for-Game-Programmers-Juicing) - [Gaffer On Games — Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Screen Shake > Trauma-based screen shake: hits add trauma (0–1) that decays over time, and the camera is offset by trauma² × smooth noise for impact. - Category: Game Feel › Camera - Level: common - Tags: screen shake, camera shake, trauma, juice, noise, impact - Updated: 2026-11-01 - URL: https://gdcs.coody.app/game-feel/camera/screen-shake ## What it is Screen shake sells impact: explosions, heavy landings, big hits. Shaking by a random offset each frame looks jittery, and stacking several shakes is hard to control. A cleaner model keeps a single **trauma** value between 0 and 1. Events add to it (a small hit +0.2, an explosion +0.6), it decays linearly over time, and the shake each frame is derived from it. Use `shake = trauma²` (or ³). Squaring makes small trauma nearly invisible and large trauma violent, so light hits stay subtle and stacked hits escalate. Multiply the shake by a maximum offset and angle, and by **smooth noise** sampled over time, one independent channel per axis. Noise gives continuous motion instead of teleporting the camera every frame. Apply the result as an offset on top of the camera's normal position (after the [deadzone follow](https://gdcs.coody.app/game-feel/camera/camera-deadzone)), then remove it next frame. Rotation (roll) shake reads strongly in 2D; in 3D small yaw and pitch also work. Offer a setting to reduce or disable shake for accessibility. ## Key formulas - **Trauma update:** $\tau \leftarrow \operatorname{clamp}(\tau + \Delta\tau_{hit} - r\,\Delta t,\; 0,\; 1)$ - **Offset per axis:** $o_x = O_{max}\,\tau^{2}\,N_1(f\,t), \quad \theta = \Theta_{max}\,\tau^{2}\,N_3(f\,t)$ — N is smooth noise in −1..1 with an independent seed per axis; f is the shake frequency (10–25 Hz). ## Rules of thumb - Trauma per event: 0.1–0.3 for small hits, 0.5–0.8 for explosions. Decay 0.8–1.5 per second. - Max offset about 1–3% of the view height; max roll 2–5°. - Noise frequency 10–25 Hz. Lower feels like rumble, higher like vibration. - Always offer a shake intensity option, including off. ## Implementation Trauma shake applied after the camera follow: ```ts const MAX_OFFSET = 0.3, MAX_ROLL = THREE.MathUtils.degToRad(3) const DECAY = 1.0, FREQ = 15 let trauma = 0, time = 0 // noise1D: any smooth 1D noise in -1..1 export const addTrauma = (amount: number) => (trauma = Math.min(1, trauma + amount)) function updateShake(dt: number, base: THREE.Vector3, settings = 1) { time += dt trauma = Math.max(0, trauma - DECAY * dt) const shake = trauma * trauma * settings const t = time * FREQ camera.position.copy(base) // follow result, never mutated camera.position.x += MAX_OFFSET * shake * noise1D(t, 1) camera.position.y += MAX_OFFSET * shake * noise1D(t, 2) camera.rotation.z = MAX_ROLL * shake * noise1D(t, 3) } ``` ## Common mistakes - Using `Math.random()` per frame, which looks like jitter and depends on frame rate. - Adding the shake offset to the follow position permanently, so the camera drifts away. - Shaking linearly with trauma, so every tiny hit wobbles the screen. - Shaking for gameplay events the player does not cause, until it becomes noise and fatigue. - No accessibility toggle. Strong shake causes discomfort for many players. ## Interactive visual The cyan player takes a quick double hit, then a single one, on a loop. The dashed frame stays still while the world shakes inside it. The meter on the right shows trauma (yellow) and the resulting shake (orange, trauma²). Turn off squaring to see small trauma wobble too much, or switch to random noise to see the jitter it causes. ## Prompt ```text Implement trauma-based camera shake in TypeScript for a Three.js game. Keep trauma in [0, 1]; addTrauma(amount) adds and clamps; each frame subtract decay * dt (default 1/s). Compute shake = trauma ** 2 times a user intensity setting, and offset the camera from its follow position by maxOffset * shake * noise(t * freq, seed) on x and y, plus roll maxRoll * shake * noise(t * freq, 3), using a smooth 1D value noise with a seed per channel. Restore the base transform before applying the offset each frame. Write Vitest tests: trauma from 0.6 reaches 0 after 0.6 s at 30 and 144 fps, it never exceeds 1, and the noise is continuous. ``` ## Related - [Camera Deadzone](https://gdcs.coody.app/game-feel/camera/camera-deadzone.md) — A camera deadzone is a box around the screen centre where the target moves freely. The camera only follows when the target pushes past its edge. - [Hit Stop](https://gdcs.coody.app/game-feel/timing/hit-stop.md) — Hit stop pauses the attacker and target for a few frames on impact. The brief freeze sells weight and gives the eye time to read the hit. - [Invincibility Frames](https://gdcs.coody.app/game-feel/timing/invincibility-frames.md) — Invincibility frames (i-frames) make a character ignore damage for a short window after a hit or during a dodge, with blinking to show it. - [Seeded Noise](https://gdcs.coody.app/mathematics/procedural/seeded-noise.md) — A seeded PRNG plus gradient noise and fBm turns one integer into an endless, smooth, repeatable world: same seed, same terrain on every machine. ## Further reading - [GDC Vault — Math for Game Programmers: Juicing Your Cameras With Math](https://www.gdcvault.com/play/1023146/Math-for-Game-Programmers-Juicing) - [Gaffer On Games — Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Object Pooling > Pre-allocate bullets, effects and enemies once, then hand out and return them. No allocation or GC spikes mid-fight, and a hard cap on live objects. - Category: Systems › Patterns - Level: core - Tags: pool, memory, garbage collection, projectiles, performance - Updated: 2026-11-01 - URL: https://gdcs.coody.app/systems/patterns/object-pooling ## What it is Spawning a bullet with `new Mesh()` and destroying it later costs an allocation, scene-graph work and, in JavaScript, garbage that the collector must clean up at a moment you do not choose. An **object pool** creates N objects at load time. `acquire()` hands out a free one, `release()` gives it back. Nothing is created or destroyed during play. The usual structure is an array of objects plus a **free list**: a stack of free indices. Both operations are O(1). Released objects are reset (position, velocity, timers, visibility) so they come back clean. In Three.js, hiding a pooled mesh (`visible = false`) or writing it into an `InstancedMesh` slot is far cheaper than adding and removing it from the scene. Decide what happens when the pool runs dry: **drop** the request (fine for cosmetic sparks), **recycle the oldest** live object (good for decals and shell casings), or **grow** (allocates, so log it and size the pool better). Pools pair naturally with a [particle system](https://gdcs.coody.app/systems/patterns/particle-system), which is a pool of tiny structs. ## Key formulas - **Pool size from Little's law:** $N \approx \lambda \cdot L \cdot k$ — λ = spawns per second, L = average lifetime in seconds, k ≈ 1.2–1.5 headroom for bursts. ## Rules of thumb - Size from data: 10 shots/s × 1.5 s lifetime = 15 live; allocate 20. - Warm the pool during loading, not on the first shot. Also pre-compile materials so the first use doesn't hitch. - Track `allocations` and `peakActive` in a debug overlay. After warm-up allocations should stay flat. - Reset in `release()`, not in `acquire()`, so a free object never holds stale references (targets, callbacks) that keep other objects alive. - Pool only things that churn: projectiles, hit effects, damage numbers, audio voices. A boss spawned once does not need a pool. ## Implementation Index-based pool with a free-list stack: ```ts class Pool { items: T[] = [] active: boolean[] = [] private free: number[] = [] constructor(create: () => T, size: number, private reset: (t: T) => void) { for (let i = size - 1; i >= 0; i--) { this.items[i] = create() this.active[i] = false this.free.push(i) } } acquire(): number { const i = this.free.pop() if (i === undefined) return -1 // exhausted: caller decides this.active[i] = true return i } release(i: number) { if (!this.active[i]) return // guard double release this.active[i] = false this.reset(this.items[i]) this.free.push(i) } } ``` Pooled bullets as hidden meshes: ```ts const bullets = new Pool(() => { const m = new THREE.Mesh(bulletGeo, bulletMat) m.visible = false scene.add(m) // added once, never removed return m }, 32, (m) => { m.visible = false }) function fire(origin: THREE.Vector3) { const i = bullets.acquire() if (i < 0) return bullets.items[i].position.copy(origin) bullets.items[i].visible = true } ``` ## Common mistakes - Forgetting to reset state, so a recycled enemy spawns with last life's health or a still-running timer. - Releasing the same object twice, which puts its index on the free list twice and later hands one object to two owners. - Keeping references to a released object (homing target, UI marker). Use the index plus a generation counter to detect stale handles. - Calling `scene.add`/`scene.remove` on acquire and release. Toggle `visible` or write instance matrices instead. - Pooling everything, which adds complexity and memory for objects that are created once. ## Interactive visual The turret fires orange projectiles from a fixed pool. Each cube in the row behind it is one pool slot: cyan = in use, gray = free. The allocation counter never moves; when every slot is busy the turret flashes red and the shot is dropped, or the oldest projectile is recycled. Raise the fire rate or shrink the pool to hit the limit. ## Prompt ```text Write a generic `Pool` in TypeScript for a Three.js game. Constructor takes a factory, a capacity and a reset callback; all objects are created up front. `acquire()` returns a slot index or -1 using a free-list stack (O(1), no allocation); `release(i)` resets the object, ignores double releases and pushes the index back. Add an optional overflow policy: 'drop' | 'recycleOldest' | 'grow' (track spawn order for recycleOldest), and counters for allocations and peak active. Use it for bullets as pre-added hidden meshes. Write Vitest tests: 1000 acquire/release cycles keep allocations at capacity, exhaustion returns -1, double release is ignored, recycleOldest returns the earliest-acquired slot. ``` ## Related - [Particle System](https://gdcs.coody.app/systems/patterns/particle-system.md) — An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. - [Tunneling & Continuous Collision](https://gdcs.coody.app/systems/collision/tunneling.md) — A fast object can be on one side of a thin wall at one tick and past it at the next. Swept tests (CCD) check the whole path between positions. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [MDN — Memory management (garbage collection)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Memory_management) - [Three.js docs — InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Particle System > An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. - Category: Systems › Patterns - Level: core - Tags: particles, emitter, instancing, vfx, pooling - Updated: 2026-11-01 - URL: https://gdcs.coody.app/systems/patterns/particle-system ## What it is A particle system has three parts. The **emitter** decides how many particles to spawn this frame (rate × dt, carrying the fraction), where, and with what initial velocity (a cone, sphere or mesh surface plus randomness). The **simulation** ages each particle, applies gravity, drag and wind, and kills it when `age ≥ life`. The **renderer** draws the survivors, varying size, color and opacity by normalized age `age / life`. Store particles as **struct-of-arrays** (`Float32Array` per field) in a fixed-capacity buffer. That buffer is an [object pool](https://gdcs.coody.app/systems/patterns/object-pooling) for plain numbers. Keep live particles packed at the front and remove a dead one by copying the last live particle into its slot. The update loop then has no holes and no allocation. Render with a single `THREE.InstancedMesh` (or `Points`) and set `mesh.count` to the live count, so the GPU draws exactly the live particles in one call. Above roughly 10k–50k particles, move the simulation to the GPU (compute or ping-pong textures). Below that, a tight CPU loop is simpler and fast enough. ## Key formulas - **Steady-state population:** $N = r \cdot L$ — r = particles per second, L = lifetime. 200/s × 1.5 s ≈ 300 alive. Size the buffer from this. - **Per-particle update (semi-implicit Euler):** $v \mathrel{+}= g\,\Delta t, \quad v \mathrel{*}= (1 - k\,\Delta t), \quad p \mathrel{+}= v\,\Delta t$ - **Normalized age drives the look:** $u = \frac{\text{age}}{L} \in [0,1], \quad \text{size} = s_0 (1-u)$ ## Rules of thumb - Typical gameplay effects: 20–300 particles, 0.3–2 s lifetimes. A muzzle flash is a burst of 10–20 particles, not a stream. - Carry the fractional part of `rate × dt` between frames, or low rates stall and high frame rates under-emit. - Clamp `dt` (e.g. ≤ 1/20 s) for the particle sim so a hitch doesn't fling everything. - Use additive blending and `depthWrite: false` for glowing effects; sort or use alpha-to-coverage only when you need correct translucency. - Randomize at spawn (speed ±20 %, life ±25 %, direction within a cone) instead of per frame. ## Implementation Swap-remove update over struct-of-arrays: ```ts function update(p: Particles, dt: number, g: number) { let i = 0 while (i < p.count) { p.age[i] += dt if (p.age[i] >= p.life[i]) { p.copy(--p.count, i) // last live particle fills the hole continue // re-process slot i } p.vy[i] -= g * dt p.px[i] += p.vx[i] * dt p.py[i] += p.vy[i] * dt p.pz[i] += p.vz[i] * dt i++ } } ``` Upload to one InstancedMesh: ```ts for (let i = 0; i < p.count; i++) { const u = p.age[i] / p.life[i] dummy.position.set(p.px[i], p.py[i], p.pz[i]) dummy.scale.setScalar(size * (1 - u)) dummy.updateMatrix() mesh.setMatrixAt(i, dummy.matrix) mesh.setColorAt(i, color.copy(young).lerp(old, u)) } mesh.count = p.count mesh.instanceMatrix.needsUpdate = true mesh.instanceColor!.needsUpdate = true ``` ## Common mistakes - Creating a `Mesh` per particle. Hundreds of draw calls will stall the CPU long before the GPU is busy. - Using `Math.floor(rate * dt)` without carrying the remainder: at 144 fps and 60 particles/s you emit nothing. - Forgetting `instanceMatrix.needsUpdate = true` (and the color attribute), so nothing moves. - Leaving frustum culling on with a stale bounding sphere: the whole effect pops out when its origin leaves the screen. Update the bounds or set `frustumCulled = false` for small effects. ## Interactive visual One InstancedMesh draws every particle: they are born yellow at the emitter, fade to orange and shrink as they age, and fall under gravity. The ring of cyan dots marks the spawn cone. Change rate and lifetime to see the live count follow rate × lifetime, and set gravity to 0 for a fountain that drifts. ## Prompt ```text Build a CPU particle system for Three.js in TypeScript. Use a fixed-capacity struct-of-arrays buffer (Float32Array for position, velocity, age, life) with swap-remove of dead particles. Emitter: rate per second with a fractional carry, spawn in a cone (half-angle param) with speed and life randomized ±20 %. Update with gravity and linear drag, dt clamped to 1/20 s. Render with one InstancedMesh: mesh.count = live count, size shrinks and color lerps from yellow to orange with normalized age. No allocation in the frame loop. Write Vitest tests: population settles near rate × lifetime, capacity is never exceeded, and removing dead particles keeps survivors' data intact. ``` ## Related - [Object Pooling](https://gdcs.coody.app/systems/patterns/object-pooling.md) — Pre-allocate bullets, effects and enemies once, then hand out and return them. No allocation or GC spikes mid-fight, and a hard cap on live objects. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Steering Behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors.md) — Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. ## Further reading - [Three.js docs — InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh) - [Three.js example — instancing performance](https://threejs.org/examples/#webgl_instancing_performance) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Fixed Timestep > Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - Category: Systems › Simulation - Level: core - Tags: timestep, accumulator, interpolation, determinism, physics - Updated: 2026-11-01 - URL: https://gdcs.coody.app/systems/simulation/fixed-timestep ## What it is Integrating physics with the raw frame delta makes the simulation depend on frame rate. Jumps reach different heights at 30 and 144 fps, springs explode after a hitch, and replays drift. A **fixed timestep** always advances the simulation by the same `dt` (for example 1/60 s) no matter how long the frame took. Each frame adds the real elapsed time to an **accumulator**, then runs as many fixed steps as fit, keeping the remainder. Clamp the frame time first (e.g. to 0.25 s). Without that clamp a slow frame queues many steps, those steps make the next frame slow too, and the game falls into the **spiral of death**. The leftover time means rendering is up to one step behind. Keep the previous and current physics states and draw `lerp(prev, curr, alpha)` with `alpha = accumulator / dt`. Motion then looks smooth even when the physics rate is lower than the display's, or doesn't divide evenly into it (60 Hz physics on a 144 Hz monitor). ## Key formulas - **Accumulator:** $A \leftarrow A + \min(\Delta t_{\text{frame}},\ \Delta t_{\max}); \quad \text{while } A \ge h:\ \text{step}(h),\ A \leftarrow A - h$ - **Render interpolation:** $\alpha = \frac{A}{h}, \qquad x_{\text{draw}} = x_{\text{prev}} + \alpha\,(x_{\text{curr}} - x_{\text{prev}})$ — For rotations, slerp quaternions with the same α. ## Rules of thumb - Common rates: 50 or 60 Hz for gameplay physics, 120–240 Hz for stiff springs, vehicles and ragdolls. - Clamp frame time to 0.1–0.25 s and/or cap steps per frame at about 5–8. - Read input once per frame, but apply forces inside each fixed step. - Don't interpolate teleports. Copy curr into prev when you snap an object so it doesn't streak across the map. - Cameras that follow an interpolated object must read the interpolated position, or they jitter against it. ## Implementation Fixed update with interpolation: ```ts const STEP = 1 / 60 let acc = 0 let last = performance.now() / 1000 function frame(nowMs: number) { const now = nowMs / 1000 acc += Math.min(now - last, 0.25) // avoid the spiral of death last = now while (acc >= STEP) { prev.copy(curr) // keep the state we step from physicsStep(curr, STEP) acc -= STEP } const alpha = acc / STEP mesh.position.lerpVectors(prev.position, curr.position, alpha) mesh.quaternion.slerpQuaternions(prev.rotation, curr.rotation, alpha) renderer.render(scene, camera) requestAnimationFrame(frame) } ``` ## Common mistakes - Clamping `dt` and calling it a fixed step: the simulation still depends on frame rate whenever frames are faster than the clamp. - No upper bound on frame time, so after a tab switch or breakpoint the game runs thousands of steps and freezes. - Rendering the current state without interpolation: at 30 Hz physics on a 60 Hz screen, motion stutters in steps. - Applying a one-off impulse (jump) once per frame instead of in the next fixed step, so it fires zero or two times. - Assuming fixed steps alone make a game deterministic. Iteration order, float math across platforms and unseeded randomness also matter. ## Interactive visual Two balls bounce from the same height. The orange one integrates with jittery frame times, so its bounces drift off the dashed line. The cyan one runs fixed physics steps and keeps hitting the line. The faint ball behind the cyan one is the raw physics state: turn interpolation off to see the cyan ball snap between ticks, and lower physics Hz to exaggerate it. ## Prompt ```text Implement a fixed-timestep game loop in TypeScript for a Three.js app. A `FixedStepper` class takes a step (default 1/60) and a max frame time (0.25 s); `advance(frameTime, step)` accumulates the clamped time, calls step(dt) as many times as fits and returns the count; an `alpha` getter returns accumulator/dt. Each physics body stores prev and curr position + quaternion; before stepping copy curr→prev; render with lerpVectors/slerpQuaternions using alpha; provide `teleport()` that sets both. Write Vitest tests with power-of-two steps: 30 fps rendering at 60 Hz physics gives 2 steps per frame, step totals over 1 s don't depend on frame pattern, a 5 s frame is clamped, and alpha equals the leftover fraction. ``` ## Related - [Tunneling & Continuous Collision](https://gdcs.coody.app/systems/collision/tunneling.md) — A fast object can be on one side of a thin wall at one tick and past it at the next. Swept tests (CCD) check the whole path between positions. - [Particle System](https://gdcs.coody.app/systems/patterns/particle-system.md) — An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. - [Steering Behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors.md) — Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. ## Further reading - [Gaffer On Games — Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/) - [MDN — Anatomy of a video game (main loop)](https://developer.mozilla.org/en-US/docs/Games/Anatomy) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Hitboxes & Hurtboxes > Attacks own hitboxes that exist only during active frames; characters own hurtboxes. An overlap is a hit, registered once per swing per target. - Category: Systems › Collision - Level: core - Tags: combat, frame data, collision, melee, overlap - Updated: 2026-11-01 - URL: https://gdcs.coody.app/systems/collision/hitbox-hurtbox ## What it is Combat collision uses separate volumes from physics. A **hurtbox** is where a character can be hurt. It usually follows the body with a few simple boxes or capsules. A **hitbox** is where an attack deals damage. It is attached to the weapon or limb and exists **only during the attack's active frames**. A hit happens when an active hitbox overlaps an enemy hurtbox. Attacks are described by **frame data**: *startup* frames (wind-up, no hitbox), *active* frames (hitbox on), and *recovery* frames (hitbox off, attacker still committed). Count these in fixed simulation ticks, not animation time, so they stay exact at any frame rate. See [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep). A hitbox usually overlaps the target for several active frames in a row, so each swing keeps a **hit registry**: the set of targets already hit. Clear it when the swing starts and skip any target already in it. Multi-hit moves clear it on purpose every few frames. For very fast weapons, sweep the hitbox between frames, the same idea as [tunneling](https://gdcs.coody.app/systems/collision/tunneling) prevention, so it can't skip past a thin hurtbox. ## Rules of thumb - Light attacks at 60 fps: about 4–7 startup, 2–4 active, 8–15 recovery frames. Heavy attacks: 12–20 startup, 4–8 active. - Make hurtboxes slightly smaller than the visible body and hitboxes slightly larger than the weapon. Hits that look like they connected should connect. - Use collision layers: player hitboxes test only enemy hurtboxes, so you never hit yourself or your teammates by accident. - Resolve all overlaps for a tick first, then apply damage, knockback and hit-stop in one pass, so the order objects update in doesn't matter. - Put a debug-draw toggle in from day one: hurtboxes green, inactive hitboxes gray, active hitboxes red. ## Implementation Per-tick attack update with a once-per-swing registry: ```ts type Phase = "startup" | "active" | "recovery" | "done" function phase(f: number, a: Attack): Phase { if (f < a.startup) return "startup" if (f < a.startup + a.active) return "active" if (f < a.startup + a.active + a.recovery) return "recovery" return "done" } function tickAttack(atk: AttackState, targets: Fighter[]) { if (atk.frame === 0) atk.alreadyHit.clear() // new swing if (phase(atk.frame, atk.data) === "active") { atk.hitbox.copy(atk.data.box).translate(atk.owner.weaponTip) for (const t of targets) { if (atk.alreadyHit.has(t.id)) continue if (atk.hitbox.intersectsBox(t.hurtbox)) { atk.alreadyHit.add(t.id) queueHit(atk.owner, t, atk.data.damage) } } } atk.frame++ } ``` ## Common mistakes - Leaving the hitbox on for the whole animation, so wind-up and follow-through also deal damage. - No hit registry: a 4-frame active window that overlaps for 3 frames deals triple damage. - Driving active frames from animation time with a variable delta. At low frame rates the active window can be skipped entirely. - Using the render mesh or physics collider as the hurtbox. Combat volumes should be simple, deliberate and tunable. ## Interactive visual The cyan attacker swings on a loop. Its hitbox is a gray outline during wind-up and recovery and turns solid red only on active frames. The target's green hurtbox flashes orange on a hit, and the orange dots count hits from the current swing. The bar underneath is the frame data (gray startup, red active, purple recovery, white playhead). Turn off one hit per swing and a single swing lands several hits. ## Prompt ```text Implement melee hit detection for a Three.js action game in TypeScript. Each attack has frame data {startup, active, recovery} counted in fixed 60 Hz ticks and a hitbox (THREE.Box3) attached to a weapon bone offset; each fighter has a hurtbox Box3 updated each tick from its root. During active frames only, test the attacker's hitbox against hurtboxes on the opposing layer; keep a per-swing Set of already-hit fighter ids cleared at frame 0; queue hits and resolve them after all overlaps are gathered. Add a debug renderer (Box3Helper: hurtbox green, inactive hitbox gray, active red). Write Vitest tests: phase boundaries at the exact frames, a hitbox overlapping for 3 active frames yields one hit, a different target can still be hit in the same swing. ``` ## Related - [Tunneling & Continuous Collision](https://gdcs.coody.app/systems/collision/tunneling.md) — A fast object can be on one side of a thin wall at one tick and past it at the next. Swept tests (CCD) check the whole path between positions. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Finite State Machine](https://gdcs.coody.app/ai/decision/finite-state-machine.md) — An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. ## Further reading - [Three.js docs — Box3 (intersectsBox)](https://threejs.org/docs/#api/en/math/Box3) - [MDN — 2D collision detection](https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Tunneling & Continuous Collision > A fast object can be on one side of a thin wall at one tick and past it at the next. Swept tests (CCD) check the whole path between positions. - Category: Systems › Collision - Level: core - Tags: ccd, swept, collision, bullets, physics - Updated: 2026-11-01 - URL: https://gdcs.coody.app/systems/collision/tunneling ## What it is Discrete collision checks for overlap only at the positions where objects end each tick. If an object moves farther per tick than the wall is thick plus its own size, both samples can miss the wall and the object **tunnels** through. Bullets, fast balls, dashing characters and thin floors are the classic cases. **Continuous collision detection (CCD)** checks the motion between ticks instead. For a sphere moving from `p0` to `p1`, a swept test returns the earliest time of impact `t ∈ [0, 1]`: cast the segment against the obstacle grown by the radius (a Minkowski sum). Against a box, that is a [raycast](https://gdcs.coody.app/threejs/rendering/raycasting) against a box expanded by `r`, solved with the slab method. Engines offer CCD per body (Box2D's `bullet` flag, Rapier's `ccdEnabled`, Unity's continuous mode) because it costs more. Turn it on only for small, fast bodies. Cheaper alternatives are **substepping** (several smaller steps per tick), thicker colliders, a maximum speed, or treating bullets as **hitscan** rays when travel time doesn't matter. ## Key formulas - **Tunneling risk:** $v\,\Delta t > w + 2r$ — Distance per tick exceeds wall thickness w plus the object's diameter. 60 u/s at 60 Hz moves 1 u per tick. - **Swept segment vs slab (one axis):** $t_{0,1} = \frac{(\text{min}-r) - p_0}{d},\ \frac{(\text{max}+r) - p_0}{d}, \quad t_{\text{hit}} = \max_{\text{axes}} t_{\text{enter}}$ — Hit if max(enter) ≤ min(exit) and 0 ≤ t_hit ≤ 1. - **Substeps needed:** $n = \left\lceil \frac{v\,\Delta t}{\tfrac12 (w + 2r)} \right\rceil$ ## Rules of thumb - Keep per-tick travel below about half the thinnest collider's thickness, or enable CCD for that body. - Hitscan anything faster than about 100 u/s with a short flight time, and simulate the slower projectiles (rockets, arrows). - Make walls at least about 0.2 u thick even when they look thin. Collider thickness doesn't have to match the visible mesh. - After a swept hit, move the body to `p0 + t·(p1 − p0)` minus a small skin (about 0.001–0.01 u) so it doesn't start the next tick inside the wall. ## Implementation Swept sphere vs box via the slab method: ```ts // Returns entry time t in [0,1] or null. Box grown by radius r. function sweptSphereAabb(p0: V3, p1: V3, r: number, min: V3, max: V3) { let tIn = 0, tOut = 1 for (const k of ["x", "y", "z"] as const) { const d = p1[k] - p0[k] const lo = min[k] - r, hi = max[k] + r if (Math.abs(d) < 1e-12) { if (p0[k] < lo || p0[k] > hi) return null continue } let t0 = (lo - p0[k]) / d, t1 = (hi - p0[k]) / d if (t0 > t1) [t0, t1] = [t1, t0] tIn = Math.max(tIn, t0) tOut = Math.min(tOut, t1) if (tIn > tOut) return null } return tIn } ``` Per-tick bullet update: ```text next = pos + vel * dt t = sweptTest(pos, next, radius, world) if t != null: pos = lerp(pos, next, t); onHit() else: pos = next ``` ## Common mistakes - Testing only `overlap(next)`. It works at walking speed and fails silently once speed upgrades land. - Using a bigger timestep on low-end devices without adjusting CCD, so tunneling appears only on slow machines. - Ignoring the radius: casting the centre as a thin ray lets grazing shots slip past corners. - Turning CCD on for every body and then wondering where the frame time went. ## Interactive visual Two bullets fire at the same speed at a thin wall. The dots behind each one are the positions it was actually tested at, one per physics tick. The top lane tests only those dots, so it lands on both sides of the wall and passes through (red). The bottom lane sweeps the path between them and stops at the wall (orange spark). Lower the speed or raise the tick rate until both lanes hit. ## Prompt ```text Add continuous collision for fast projectiles in a TypeScript Three.js game. Write `sweptSphereAabb(p0, p1, r, min, max): number | null` (slab method, box expanded by r, returns entry t in [0,1], 0 if starting inside) and `sweptSphereSphere(p0, p1, r, c, R)` (quadratic, earliest root, null if moving away). In the fixed-step bullet update, compute next = pos + vel*dt, find the earliest t across nearby colliders, place the bullet at the contact point minus a 0.005 skin and fire onHit. Write Vitest tests: a 0.05-radius bullet at 60 u/s with 1/60 s ticks against a 0.1-thick wall is missed by a discrete overlap test but caught by the swept test at the correct x; parallel segments outside the slab return null. ``` ## Related - [Raycasting](https://gdcs.coody.app/threejs/rendering/raycasting.md) — A ray is an origin plus a direction. Raycasting returns the nearest surface it crosses, with hit point, distance and surface normal. - [Hitboxes & Hurtboxes](https://gdcs.coody.app/systems/collision/hitbox-hurtbox.md) — Attacks own hitboxes that exist only during active frames; characters own hurtboxes. An overlap is a hit, registered once per swing per target. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [Box2D documentation — Simulation (continuous collision)](https://box2d.org/documentation/md_simulation.html) - [Three.js docs — Ray.intersectBox](https://threejs.org/docs/#api/en/math/Ray) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # A* Pathfinding > A* searches a graph by always expanding the node with the lowest cost so far plus estimated cost to go. With an admissible heuristic, the path is optimal. - Category: AI › Navigation - Level: core - Tags: pathfinding, graph search, heuristic, grid, navmesh - Updated: 2026-11-01 - URL: https://gdcs.coody.app/ai/navigation/a-star-pathfinding ## What it is A* finds the cheapest path through a graph: grid cells, waypoints or navmesh polygons. For each node it tracks `g`, the best known cost from the start, and `h`, a **heuristic** estimate of the cost to the goal. It keeps an **open set** of discovered nodes ordered by `f = g + h`. Each iteration it pops the lowest-f node, moves it to the **closed set**, and relaxes its neighbors. When the goal is popped, follow parent links back to the start. The heuristic is what makes it fast. `h = 0` turns A* into Dijkstra's algorithm, which floods outward in every direction. A perfect `h` walks straight to the goal. If `h` never **overestimates** the true cost (admissible), the first path to the goal is optimal. Match it to your movement rules: **Manhattan** for 4-way grids and **octile** for 8-way grids with diagonal cost √2. Use a **binary heap** for the open set. A sorted array or a linear scan turns a few-millisecond search into a frame-killer. Big worlds want a coarser graph (navmesh, hierarchical clusters), searches spread over several frames, and path smoothing with string-pulling or line-of-sight checks afterwards. Hand the finished path to [steering behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors) to actually move along it. ## Key formulas - **Priority:** $f(n) = g(n) + w\,h(n)$ - **Manhattan (4-way):** $h = |\Delta x| + |\Delta y|$ - **Octile (8-way, diagonal = √2):** $h = |\Delta x| + |\Delta y| + (\sqrt{2} - 2)\min(|\Delta x|, |\Delta y|)$ — Weighted A* (w > 1) expands far fewer nodes; the path is at most w × optimal. ## Rules of thumb - Break ties between equal f toward larger g (or smaller h). On open grids this cuts expansions dramatically. - Use lazy deletion: push duplicates with the better key and skip popped nodes that are already closed. It's simpler than decrease-key and just as fast in practice. - On 8-way grids, forbid cutting corners: a diagonal move needs both adjacent orthogonal cells to be free. - Budget searches: cap expansions (e.g. 2,000 per frame) and resume next frame, or fall back to a partial path toward the closest node found. - Cache paths and re-plan only when the goal moves more than a cell or two, or when the map changes along the route. ## Implementation Core loop (binary heap, lazy deletion): ```ts g.fill(Infinity); g[start] = 0 open.push(start, h(start)) while (open.size) { const cur = open.pop() if (closed[cur]) continue // stale duplicate closed[cur] = 1 if (cur === goal) return rebuild(parent, goal) for (const [nb, cost] of neighbors(cur)) { if (closed[nb]) continue const ng = g[cur] + cost if (ng < g[nb]) { g[nb] = ng parent[nb] = cur open.push(nb, ng + w * h(nb)) // f = g + w·h } } } return null // unreachable ``` ## Common mistakes - Using Euclidean or Manhattan distance on an 8-way grid. Manhattan overestimates diagonals, so paths stop being optimal. - Allowing diagonal moves through wall corners, so agents clip through geometry. - Allocating node objects per search on a big grid. Reuse typed arrays (g, parent, closed) and clear them with a generation counter. - Searching to an unreachable goal every frame. Flood fill connected regions once and reject impossible requests instantly. ## Interactive visual The search animates from the green start to the red goal. Yellow tiles are the open set (frontier), blue tiles are closed (already expanded), and the orange line is the final path. Switch the heuristic to Dijkstra to watch it flood the map, or raise the weight to see it rush toward the goal, expand fewer tiles and sometimes pick a longer path. ## Prompt ```text Implement grid A* in TypeScript for a Three.js game. Input: width, height, a Uint8Array of walls, start and goal cell indices, options {diagonal: boolean, heuristic: 'manhattan' | 'octile' | 'zero', weight: number}. Use a binary min-heap with lazy deletion, tie-break toward larger g, diagonal cost √2 with no corner cutting, and typed arrays for g/parent/closed. Return {path: number[] | null, cost, expanded: number[]} so the search can be visualized. Write Vitest tests: on a hand-made map, A* cost equals Dijkstra (heuristic 'zero') cost and A* expands no more nodes; an 8-way path around a pillar costs 4, not 2√2; a goal walled off returns null. ``` ## Related - [Steering Behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors.md) — Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. - [Finite State Machine](https://gdcs.coody.app/ai/decision/finite-state-machine.md) — An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. - [Raycasting](https://gdcs.coody.app/threejs/rendering/raycasting.md) — A ray is an origin plus a direction. Raycasting returns the nearest surface it crosses, with hit point, distance and surface normal. ## Further reading - [Red Blob Games — Introduction to A*](https://www.redblobgames.com/pathfinding/a-star/introduction.html) - [Red Blob Games — Heuristics (Amit's A* pages)](https://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Steering Behaviors > Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. - Category: AI › Steering - Level: core - Tags: steering, seek, arrive, separation, flocking, movement - Updated: 2026-11-01 - URL: https://gdcs.coody.app/ai/steering/steering-behaviors ## What it is Craig Reynolds' steering behaviors treat an agent as a point with a velocity, a maximum speed and a maximum force. Each behavior answers one question: *what velocity would I like right now?* The steering force is that **desired velocity minus the current velocity**, capped at `maxForce`. Because the change per step is limited, the agent turns and accelerates smoothly instead of snapping. **Seek** wants full speed straight at the target. It gets there fast but overshoots and oscillates. **Arrive** is seek with a brake: inside a slowing radius the desired speed falls linearly to zero, so the agent comes to rest on the target. **Flee** is seek reversed. **Separation** pushes away from neighbors inside a radius, weighted by 1/distance, so a crowd heading for the same point spreads out instead of stacking. Combine behaviors with a weighted sum (e.g. arrive × 1 + separation × 1.5), or with a priority list where the most urgent behavior that returns a non-zero force wins. Steering is the layer below planning: [A*](https://gdcs.coody.app/ai/navigation/a-star-pathfinding) chooses the route, a [state machine](https://gdcs.coody.app/ai/decision/finite-state-machine) chooses the goal, and steering moves the body. ## Key formulas - **Steering:** $\vec F = \operatorname{trunc}\!\left(\frac{\vec v_{\text{desired}} - \vec v}{\tau},\ F_{\max}\right)$ — τ = reaction time (e.g. 0.25 s); smaller values react faster. - **Arrive desired speed:** $|\vec v_{\text{desired}}| = v_{\max} \cdot \min\!\left(1, \frac{d}{R_{\text{slow}}}\right)$ - **Separation:** $\vec F_{\text{sep}} = \sum_{d_j < R} \frac{\vec p - \vec p_j}{\lVert \vec p - \vec p_j \rVert^{2}}$ ## Rules of thumb - Start with maxSpeed 3–6 u/s, maxForce 2–4× maxSpeed per second, a slowing radius of 2–3 body lengths, and a separation radius of about 2 body radii. - Integrate in a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep). Steering with large dt overshoots and jitters. - Use a spatial grid for neighbor queries once you pass about 50 agents. The naive separation loop is O(n²). - Face the velocity direction, smoothed, rather than the force. Forces flicker frame to frame. - Add a small arrival tolerance (e.g. 0.05 u) and zero the velocity inside it to avoid micro-jitter. ## Implementation Arrive + separation, allocation-free: ```ts function steer(a: Agent, i: number, all: Agent[], target: THREE.Vector2, dt: number) { // Arrive tmp.subVectors(target, a.pos) const d = tmp.length() const speed = MAX_SPEED * Math.min(1, d / SLOW_RADIUS) if (d > 1e-6) tmp.multiplyScalar(speed / d) force.subVectors(tmp, a.vel).divideScalar(REACTION) // Separation for (let j = 0; j < all.length; j++) { if (j === i) continue tmp.subVectors(a.pos, all[j].pos) const d2 = tmp.lengthSq() if (d2 > 0 && d2 < SEP_R * SEP_R) force.addScaledVector(tmp, SEP_W / d2) } force.clampLength(0, MAX_FORCE) a.vel.addScaledVector(force, dt).clampLength(0, MAX_SPEED) a.pos.addScaledVector(a.vel, dt) } ``` ## Common mistakes - Setting velocity to the desired velocity directly: that's teleport-steering, with no inertia and instant 180° turns. - Using seek for the final approach. It never settles; switch to arrive near the goal. - Summing many behaviors with equal weights, so they cancel and agents freeze. Prioritize, or tune the weights. - Normalizing a zero-length vector when the agent is exactly on the target, which gives NaN positions. ## Interactive visual Cyan agents chase the orange target as it hops between spots. Yellow lines are their velocities, and the gray ring is the arrive slowing radius. Switch to seek to see them overshoot and buzz around the target, and set separation to 0 to watch them pile into one spot. ## Prompt ```text Implement Reynolds steering behaviors in TypeScript for a Three.js crowd on the XZ plane. Agents have pos/vel (THREE.Vector2), maxSpeed, maxForce. Write seek, arrive (linear slow-down inside slowRadius), flee and separation (1/d weighting within a radius), each writing into an output vector with no allocation; steering = (desired − vel) / reactionTime, truncated to maxForce; combine with weights. Integrate at a fixed 60 Hz, orient each instanced cone along its velocity, and guard zero-length vectors. Write Vitest tests: arrive ends within 0.05 u of a static target with near-zero speed; seek passes the target at near max speed; separation between two agents 0.5 u apart points away from the neighbor with magnitude 2. ``` ## Related - [A* Pathfinding](https://gdcs.coody.app/ai/navigation/a-star-pathfinding.md) — A* searches a graph by always expanding the node with the lowest cost so far plus estimated cost to go. With an admissible heuristic, the path is optimal. - [Finite State Machine](https://gdcs.coody.app/ai/decision/finite-state-machine.md) — An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [Craig Reynolds — Steering Behaviors For Autonomous Characters](https://www.red3d.com/cwr/steer/) - [Craig Reynolds — Boids](https://www.red3d.com/cwr/boids/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Finite State Machine > An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. - Category: AI › Decision making - Level: core - Tags: fsm, state machine, ai, behavior, transitions - Updated: 2026-11-01 - URL: https://gdcs.coody.app/ai/decision/finite-state-machine ## What it is A finite state machine is a set of **states**, one of which is current, plus **transitions** triggered by events or conditions. Each state owns its behavior: Patrol walks waypoints, Chase steers toward the player, Attack plays a swing. Because only one state runs, you never get the contradictory combinations that a pile of booleans (`isChasing && isIdle`) allows. Write transitions as data, `table[state][event] → nextState`, and any event not in the table is ignored. The table is the design document: you can draw it, review it and test it. Give each state `enter`, `update` and `exit` hooks for one-off work like starting an animation, resetting a timer or releasing a target, and track **time in state** for timeouts such as "idle for 1.5 s, then patrol". Flat FSMs grow quickly: n states can need up to n² transitions. When many states share an exit ("any state → Dead"), use wildcard transitions or a **hierarchical** FSM (Combat as a parent of Chase and Attack). When decisions involve scoring many options, behavior trees or utility AI scale better. FSMs still drive animation, UI flows and the [hitbox frame data](https://gdcs.coody.app/systems/collision/hitbox-hurtbox) of each attack. ## Rules of thumb - Evaluate sensor checks (distance, line of sight) once per AI tick, often at 5–10 Hz rather than every frame, and turn them into events. - Add hysteresis: spot the player at 6 u but lose them at 8 u, or the agent flickers between Patrol and Chase at the boundary. - Give committed states (Attack, Stagger) a minimum duration and ignore other events until it ends. - Log every transition `from --event--> to` in debug builds. Most AI bugs show up there. - Keep per-state data (patrol index, attack cooldown) on the agent, not in the state, so one table can run many agents. ## Implementation Transition table + driver: ```ts type S = "idle" | "patrol" | "chase" | "attack" type E = "rested" | "spotted" | "inRange" | "done" | "lost" const table: Record>> = { idle: { rested: "patrol", spotted: "chase" }, patrol: { spotted: "chase" }, chase: { inRange: "attack", lost: "idle" }, attack: { done: "chase" }, } function send(ai: Guard, e: E) { const next = table[ai.state][e] if (!next) return // not legal here: ignore states[ai.state].exit?.(ai) ai.state = next ai.timeInState = 0 states[next].enter?.(ai) } ``` Turning sensors into events each AI tick: ```text d = distance(guard, player) if state == idle and timeInState > 1.5: send(rested) if d < SEE_RADIUS: send(spotted) if d < ATTACK_RANGE: send(inRange) if d > LOSE_RADIUS: send(lost) if state == attack and timeInState > 0.6: send(done) ``` ## Common mistakes - Making transitions implicit: states set `ai.state = ...` from anywhere, so nobody can see the full graph. - No hysteresis or minimum time, so agents oscillate between two states every frame. - Doing one-off work (play sound, start animation) in `update` instead of `enter`, so it repeats every tick. - Cramming everything into one flat FSM until it has 30 states. Split it hierarchically or move to a behavior tree. ## Interactive visual The guard on the left changes color with its state: gray Idle, green Patrol, yellow Chase, red Attack. The diagram on the right shows the same four states as nodes. The lit node is the current state and the bright edge is the transition just taken. The orange player wanders in, holds still to get attacked, then dashes away so the guard loses track and goes idle. ## Prompt ```text Write a table-driven finite state machine in TypeScript for enemy AI in a Three.js game. Generic `StateMachine` takes a transition table Record>>, an initial state and optional enter/exit/transition hooks; `send(e)` returns whether it transitioned and resets time-in-state; illegal events are ignored. Build a guard with Idle, Patrol, Chase and Attack states: sensors run at 10 Hz and emit events, with see radius 6 and lose radius 8 (hysteresis), attack range 1.2, and a 0.6 s attack commitment. Log transitions in dev. Write Vitest tests: legal transitions follow the table, illegal events leave the state unchanged, hooks run in order exit → transition → enter, and time in state resets. ``` ## Related - [Steering Behaviors](https://gdcs.coody.app/ai/steering/steering-behaviors.md) — Seek, arrive and separation each compute a desired velocity; steering is desired minus current velocity, capped and summed. Simple rules, lifelike motion. - [A* Pathfinding](https://gdcs.coody.app/ai/navigation/a-star-pathfinding.md) — A* searches a graph by always expanding the node with the lowest cost so far plus estimated cost to go. With an admissible heuristic, the path is optimal. - [Hitboxes & Hurtboxes](https://gdcs.coody.app/systems/collision/hitbox-hurtbox.md) — Attacks own hitboxes that exist only during active frames; characters own hurtboxes. An overlap is a hit, registered once per swing per target. ## Further reading - [MDN — State machines (JavaScript glossary)](https://developer.mozilla.org/en-US/docs/Glossary/State_machine) - [Game Programming Patterns — State](https://gameprogrammingpatterns.com/state.html) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Frame Budget > At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. - Category: Performance › CPU - Level: core - Tags: frame time, fps, profiling, vsync, milliseconds - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/cpu/frame-budget ## What it is The frame budget is the time one frame may take before the display moves on without you: `1000 / targetHz` milliseconds, so **16.67 ms** at 60 Hz, 8.33 ms at 120 Hz and 33.3 ms at 30 Hz. Input, gameplay scripts, physics, AI, animation, audio mixing and the CPU side of rendering all have to fit inside it, every frame. Think in **milliseconds, not FPS**. FPS is the reciprocal of frame time, so it hides how costs add up: dropping from 60 to 50 fps costs 3.3 ms, while 30 to 20 fps costs 16.7 ms. A feature that costs 2 ms is 12% of a 60 Hz frame no matter what the current FPS is. With vsync, missing the budget by even 0.1 ms usually means waiting for the next vertical blank, so the frame shows for 33.3 ms and the game stutters. Plan for headroom (spend about 80–90% of the budget) because spikes from garbage collection, asset loads and busy combat scenes all land on top of the average. Split heavy work you can't shrink across frames with [time slicing](https://gdcs.coody.app/performance/cpu/time-slicing), and cut per-object CPU cost with [spatial partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning) and fewer [draw calls](https://gdcs.coody.app/performance/gpu/draw-calls). ## Key formulas - **Budget per frame:** $T_{\text{budget}} = \frac{1000\ \text{ms}}{f_{\text{target}}}$ — 60 Hz → 16.67 ms, 120 Hz → 8.33 ms, 144 Hz → 6.94 ms. - **Frame time adds up:** $T_{\text{frame}} = \sum_i T_{\text{system}_i} \le T_{\text{budget}}$ - **Shown time with vsync:** $T_{\text{shown}} = \left\lceil \frac{T_{\text{frame}}}{T_{\text{budget}}} \right\rceil T_{\text{budget}}$ — 17 ms of work at 60 Hz is shown for 33.3 ms: effectively 30 fps. ## Rules of thumb - Target about 13–14 ms of work at 60 Hz (80–85%) so spikes don't cross 16.67 ms. - Track the 99th-percentile frame time, not just the average. Players feel spikes, not means. - Give each system a written budget, e.g. gameplay 3 ms, physics 2.5 ms, AI 2 ms, animation 1.5 ms, render submission 4 ms. - Time with `performance.now()` (sub-millisecond, monotonic). Browsers coarsen it, so average over many frames. - On the web, the GPU runs in parallel: CPU time and GPU time each need to fit, and the slower one sets the frame rate. ## Implementation Per-system timings with a rolling average and a budget check: ```ts const BUDGET_MS = 1000 / 60 const timings = new Map() // smoothed ms per system function timed(name: string, fn: () => void) { const t0 = performance.now() fn() const ms = performance.now() - t0 const prev = timings.get(name) ?? ms timings.set(name, prev + (ms - prev) * 0.1) // exponential moving average } function frame(dt: number) { timed("input", () => input.poll()) timed("gameplay", () => world.update(dt)) timed("physics", () => physics.step(dt)) timed("ai", () => ai.update(dt)) timed("render", () => renderer.render(scene, camera)) let total = 0 for (const ms of timings.values()) total += ms if (total > BUDGET_MS * 0.9) hud.warn(`frame ${total.toFixed(1)} ms`) } ``` ## Common mistakes - Optimising by FPS deltas instead of milliseconds, which misjudges how much a feature costs. - Spending the whole 16.67 ms on average, so every GC pause or loading hitch drops a frame. - Profiling a debug build or with DevTools open and treating the numbers as shipping performance. - Measuring only the CPU when the GPU is the bottleneck (or vice versa). Check both before optimising. - Doing one-off heavy work (pathfinding for everyone, level loads, JSON parsing) inside a single frame instead of spreading it out. ## Interactive visual The bottom bar is one frame: each coloured slice is a system (orange physics, purple AI, yellow gameplay, cyan rendering, grey other) and the white line is the budget, with any overrun in red. The bars above are recent frames (green on time, red late). Raise the load or the target rate to see frames miss. ## Prompt ```text Build a frame-budget profiler for my TypeScript + Three.js game. Provide `beginSystem(name)` / `endSystem(name)` using performance.now(), store an exponential moving average and a rolling 120-frame window per system, and compute total, average and 99th-percentile frame time. Accept a target refresh rate (default 60 Hz → 16.67 ms) and flag frames above 90% of budget. Draw a small overlay with one coloured bar per system scaled to the budget and the overrun in red. No allocations per frame (preallocate ring buffers). Write unit tests for the percentile function and for the vsync rounding rule ceil(frame/budget)·budget. ``` ## Related - [Time Slicing](https://gdcs.coody.app/performance/cpu/time-slicing.md) — Split expensive per-entity work into buckets and run one bucket per frame. Cost per frame drops by the bucket count; each entity reacts a little later. - [Draw Calls](https://gdcs.coody.app/performance/gpu/draw-calls.md) — Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info. - [Spatial Partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning.md) — Bin objects into a uniform grid so collision and proximity queries test only same-cell and neighbour-cell pairs instead of all n(n−1)/2 pairs. ## Further reading - [MDN — performance.now()](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now) - [MDN — Anatomy of a video game (main loop timing)](https://developer.mozilla.org/en-US/docs/Games/Anatomy) - [MDN — requestAnimationFrame()](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Time Slicing > Split expensive per-entity work into buckets and run one bucket per frame. Cost per frame drops by the bucket count; each entity reacts a little later. - Category: Performance › CPU - Level: common - Tags: amortization, round robin, ai, scheduling, budget - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/cpu/time-slicing ## What it is Some work is too expensive to run for every entity every frame but doesn't need to: AI re-planning, line-of-sight checks, path requests, LOD selection, sensor queries. **Time slicing** divides those entities into `B` buckets and updates one bucket per frame in round-robin order. The per-frame cost drops to about `1/B` of the total, and every entity still updates once every `B` frames. The price is **latency**. With 4 buckets at 60 Hz an agent notices a change up to 4 frames (67 ms) late. That's invisible for a guard choosing a patrol point and noticeable for an enemy dodging a bullet, so slice only work that tolerates it and keep reflex-level checks per frame. Sliced updates must use the time since that entity's **last** update, `B · dt` on average, not the current frame's `dt`, or timers and integrators run `B` times too slowly. Cheap per-frame work such as movement keeps running every frame along the last decision, so motion stays smooth while decisions are staggered. Two common shapes: **fixed buckets** (`index % B` or contiguous ranges) for predictable latency, and a **round-robin cursor with an item budget** ("update up to 50 per frame") that adapts as the entity count changes. Both keep the [frame budget](https://gdcs.coody.app/performance/cpu/frame-budget) flat instead of spiking. ## Key formulas - **Cost per frame:** $C_{\text{frame}} \approx \frac{N \cdot c}{B}$ — N entities, c ms of work each, B buckets. - **Worst-case reaction delay:** $t_{\text{delay}} \le B \cdot \Delta t$ — B = 4 at 60 Hz: up to 66.7 ms. ## Rules of thumb - Start with 2–4 buckets for AI decisions and 8–16 for slow systems (ambient life, far-away LOD, cover evaluation). - Keep reaction-critical checks (incoming damage, collision, input) out of slices. - Pass the accumulated time since the entity's last update into sliced logic. - Prefer an item budget (e.g. max 40 path requests per frame) when the count varies widely. - Give new entities a staggered start bucket so a spawned wave doesn't all think on the same frame. ## Implementation Bucketed AI updates with correct elapsed time: ```ts const BUCKETS = 4 let frame = 0 function updateAI(agents: Agent[], dt: number, now: number) { const bucket = frame++ % BUCKETS for (let i = 0; i < agents.length; i++) { const a = agents[i] a.moveAlongPlan(dt) // cheap: every frame if (i % BUCKETS !== bucket) continue const elapsed = now - a.lastThink // ≈ BUCKETS * dt a.lastThink = now a.think(elapsed) // expensive: 1 frame in 4 } } ``` Round-robin cursor with a per-frame item budget: ```text budget = 40 repeat min(budget, count) times: process(items[cursor]) cursor = (cursor + 1) mod count ``` ## Common mistakes - Passing the frame's `dt` to sliced logic, so timers and cooldowns run B times slower. - Slicing reflex checks and making enemies feel sluggish or miss hits. - Using `index % B` on a list that reorders or removes items, so some entities skip updates or run twice. Store a stable bucket per entity. - Spawning a crowd that all lands in one bucket and recreates the spike you tried to remove. ## Interactive visual Each dot is an agent chasing the yellow beacon. Only the orange bucket thinks this frame and re-aims at the beacon; cyan agents keep moving toward the goal they picked earlier (white line), so they trail behind. Raise the bucket count and watch the swarm react later and spread out; slow the tick to see the rotation. ## Prompt ```text Add time slicing to my TypeScript + Three.js game's AI. Write a `Scheduler` that holds entities with a stable `bucket` assigned at registration (round robin), a configurable bucket count B, and per-entity `lastUpdate` time. Each frame it calls `think(entity, elapsedSinceLastThink)` only for the active bucket and `move(entity, dt)` for all. Also provide a budgeted mode that processes at most K entities per frame with a wrapping cursor that survives removals. Handle B changing at runtime by reassigning buckets evenly. Write tests: every entity thinks exactly once per B frames, elapsed time ≈ B·dt, and removal mid-cycle never skips anyone. ``` ## Related - [Frame Budget](https://gdcs.coody.app/performance/cpu/frame-budget.md) — At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. - [Spatial Partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning.md) — Bin objects into a uniform grid so collision and proximity queries test only same-cell and neighbour-cell pairs instead of all n(n−1)/2 pairs. - [Chunk Streaming](https://gdcs.coody.app/performance/world/chunk-streaming.md) — Split a big world into chunks, load those within a radius of the player and unload them past a larger radius, so the boundary never thrashes. ## Further reading - [MDN — Anatomy of a video game](https://developer.mozilla.org/en-US/docs/Games/Anatomy) - [MDN — requestIdleCallback() (deferring background work)](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Draw Calls > Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info. - Category: Performance › GPU - Level: core - Tags: instancing, batching, InstancedMesh, BatchedMesh, cpu overhead - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/gpu/draw-calls ## What it is A **draw call** is one request from the CPU to the GPU: "draw this geometry with this shader and these settings". Before each one the engine and driver bind buffers, upload uniforms (the object's matrix, material values) and maybe switch shaders or textures. The GPU can draw a thousand triangles almost as fast as ten, so with many small objects the frame is limited by this **per-call CPU overhead**, not by triangle count. In Three.js every visible `Mesh` is at least one draw call (more with multi-material geometry or shadows, since each shadow-casting light renders casters again). Read `renderer.info.render.calls` after rendering to see the count. Under WebGL a few hundred calls per frame is comfortable on a laptop; several thousand will eat most of the [frame budget](https://gdcs.coody.app/performance/cpu/frame-budget). **Instancing** (`THREE.InstancedMesh`) draws N copies of one geometry and material in a single call, with a per-instance matrix and optional colour. **Batching** (`THREE.BatchedMesh`) draws many *different* geometries that share a material in one call. Static scenery can also be merged into one geometry at load time. Sharing materials and packing textures into a [texture atlas](https://gdcs.coody.app/performance/gpu/texture-atlas) is what makes batching possible. Fewer calls usually also means fewer state changes. When you can't merge, sort by shader and then material so consecutive calls reuse state, which is what Three.js does for opaque objects. ## Key formulas - **CPU submission cost:** $T_{\text{cpu}} \approx n_{\text{calls}} \cdot c_{\text{call}} + n_{\text{switches}} \cdot c_{\text{state}}$ — With c_call around 5–20 µs in WebGL, 1,000 separate meshes can cost several ms. - **Instancing:** $n_{\text{calls}}: N \;\rightarrow\; 1$ ## Rules of thumb - Check `renderer.info.render.calls` (and `.triangles`) every time a scene gets slower. - Aim for under ~200–500 draw calls per frame on web for mid-range hardware. - Use `InstancedMesh` for 50+ copies of the same mesh (trees, rocks, bullets, crowd props). - Use `BatchedMesh` or merged geometry when objects differ in shape but share a material. - Set `instanceMatrix.needsUpdate = true` only on frames where instances moved, and call `computeBoundingSphere()` after large moves so culling stays correct. ## Implementation 1,000 cubes in one draw call with `THREE.InstancedMesh`: ```ts const COUNT = 1000 const geometry = new THREE.BoxGeometry(0.4, 0.4, 0.4) const material = new THREE.MeshStandardMaterial() const cubes = new THREE.InstancedMesh(geometry, material, COUNT) const m = new THREE.Matrix4() const color = new THREE.Color() for (let i = 0; i < COUNT; i++) { m.setPosition((i % 32) - 16, 0, Math.floor(i / 32) - 16) cubes.setMatrixAt(i, m) cubes.setColorAt(i, color.setHSL(i / COUNT, 0.6, 0.5)) } cubes.instanceMatrix.needsUpdate = true cubes.instanceColor!.needsUpdate = true scene.add(cubes) renderer.render(scene, camera) console.log(renderer.info.render.calls) // 1 for the cubes, not 1000 ``` ## Common mistakes - Creating a new material per object (even with identical settings), which defeats batching and multiplies state changes. - Measuring triangles while ignoring call count, then simplifying meshes that were never the bottleneck. - Forgetting `needsUpdate` after `setMatrixAt`, so instances don't move. - Leaving the instanced mesh's bounding sphere at its initial value, so the whole batch gets frustum-culled when it shouldn't. - Merging everything into one giant mesh, which then can't be culled or updated in parts. Group by region instead. ## Interactive visual A slow-motion view of the CPU submitting one frame. As separate meshes, each cube turns orange when its own draw call is sent; as one InstancedMesh, the whole block turns cyan in a single call. The yellow bar is CPU time spent submitting. The default alternates between the two; raise the cube count to widen the gap. ## Prompt ```text Refactor my TypeScript + Three.js scene to cut draw calls. Find meshes that share geometry and material and replace each group with a THREE.InstancedMesh (copy world matrices with setMatrixAt, per-instance colour with setColorAt). Group meshes that share a material but differ in geometry into a THREE.BatchedMesh. Keep a map from the old object ids to instance ids so gameplay code can still move or hide one item (hide by scaling its matrix to zero). Recompute bounding spheres after edits. Log renderer.info.render.calls before and after, and write a test that builds 500 identical meshes and asserts the converted scene renders them with one call. ``` ## Related - [Texture Atlas](https://gdcs.coody.app/performance/gpu/texture-atlas.md) — Pack many sprites into one texture and address each by its UV rectangle, so objects share a material and batch into few draw calls. Pad to stop bleeding. - [Overdraw](https://gdcs.coody.app/performance/gpu/overdraw.md) — Overdraw is shading the same pixel more than once. Stacked transparent particles and big quads multiply fill cost; trim sprites and limit layers. - [Frame Budget](https://gdcs.coody.app/performance/cpu/frame-budget.md) — At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. ## Further reading - [Three.js docs — InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh) - [Three.js docs — BatchedMesh](https://threejs.org/docs/#api/en/objects/BatchedMesh) - [Three.js docs — WebGLRenderer.info](https://threejs.org/docs/#api/en/renderers/WebGLRenderer.info) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Overdraw > Overdraw is shading the same pixel more than once. Stacked transparent particles and big quads multiply fill cost; trim sprites and limit layers. - Category: Performance › GPU - Level: common - Tags: fill rate, transparency, particles, fragment shader, blending - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/gpu/overdraw ## What it is The GPU runs the fragment shader for every pixel each triangle covers. When several surfaces cover the same pixel, it pays that cost again for each layer: that is **overdraw**. A frame with an average overdraw of 3 shades three times as many fragments as the screen has pixels. Opaque geometry is mostly protected by the depth buffer: draw front to back (Three.js sorts opaque objects that way) and hidden fragments are rejected by early depth testing before shading. **Transparent** surfaces can't use that trick. They must be blended back to front, every layer is shaded, and they usually don't write depth. Smoke, fire, foliage cards, UI panels and light glows stack up fast. Fill cost scales with **screen area**, not object count. One full-screen particle costs as much as millions of tiny ones, and a few large soft particles near the camera can cost more than the rest of the frame. It gets worse at high resolution and device pixel ratio, so phones with 3× screens suffer most. Fixes: fewer and smaller layers, sprite geometry trimmed to the visible shape instead of a full square, lower-resolution offscreen rendering for particles, `alphaTest` or cut-out materials instead of blending where acceptable, and a cap on how close particles may get to the camera. Fewer layers also helps [draw calls](https://gdcs.coody.app/performance/gpu/draw-calls) and the [frame budget](https://gdcs.coody.app/performance/cpu/frame-budget). ## Key formulas - **Average overdraw:** $\bar{o} = \frac{\text{fragments shaded}}{\text{pixels on screen}}$ - **Fill cost:** $T_{\text{fill}} \propto W \cdot H \cdot \text{dpr}^2 \cdot \bar{o} \cdot c_{\text{frag}}$ — Doubling device pixel ratio quadruples fill cost. ## Rules of thumb - Keep average overdraw below ~2–3 and hot spots below ~8 layers on mobile. - Trim particle sprites to a 6–8 sided polygon around the opaque area: this often removes 30–50% of fragments. - Cap `renderer.setPixelRatio(Math.min(devicePixelRatio, 2))`. - Fade out and cull particles that get within ~1 unit of the camera, where one quad can fill the screen. - Use `depthWrite: false` for blended particles but keep `depthTest: true`, so opaque geometry still hides them. ## Implementation Overdraw debug view: every fragment adds 1/32 to a red channel. ```ts const overdrawMaterial = new THREE.ShaderMaterial({ transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: /* glsl */ ` void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: /* glsl */ ` void main() { gl_FragColor = vec4(1.0 / 32.0, 0.0, 0.0, 1.0); }`, }) // Debug toggle: render the scene with every material replaced. scene.overrideMaterial = showOverdraw ? overdrawMaterial : null // Brightness = layers; red 0.25 means 8 fragments shaded on that pixel. ``` ## Common mistakes - Counting particles instead of their screen coverage. - Using full square quads for round, soft sprites, so the transparent corners are shaded for nothing. - Leaving `transparent: true` on materials that are actually opaque, which turns off front-to-back sorting and early depth rejection. - Stacking several full-screen post-processing passes or UI panels without counting them as layers. ## Interactive visual A heat map of a smoke column: each pixel's colour is how many particle quads were shaded there (blue 1, green 2, yellow 3–4, orange 5–7, red 8+). Add particles or make them bigger to watch it turn red, and trim the corners to see the cost drop. ## Prompt ```text Add an overdraw debug mode to my TypeScript + Three.js game. On a key toggle, render the scene into a WebGLRenderTarget with scene.overrideMaterial set to an additive ShaderMaterial that writes 1/32 per fragment (depthTest off), then draw a full-screen quad that maps layer count to a heat ramp (0 black, 1 blue, 2 green, 3–4 yellow, 5–7 orange, 8+ red). Read the target back with readRenderTargetPixels at a 64×64 downsample to report average and max overdraw in the HUD. Restore the original materials when toggled off. Write a test that renders two full-screen quads and checks the average is 2. ``` ## Related - [Draw Calls](https://gdcs.coody.app/performance/gpu/draw-calls.md) — Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info. - [Texture Atlas](https://gdcs.coody.app/performance/gpu/texture-atlas.md) — Pack many sprites into one texture and address each by its UV rectangle, so objects share a material and batch into few draw calls. Pad to stop bleeding. - [Frame Budget](https://gdcs.coody.app/performance/cpu/frame-budget.md) — At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. ## Further reading - [Three.js docs — Material (transparent, depthWrite, blending)](https://threejs.org/docs/#api/en/materials/Material) - [Khronos OpenGL wiki — Early fragment test](https://www.khronos.org/opengl/wiki/Early_Fragment_Test) - [GPU Gems 3 — High-speed, off-screen particles](https://developer.nvidia.com/gpugems/gpugems3/part-iv-image-effects/chapter-23-high-speed-screen-particles) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Texture Atlas > Pack many sprites into one texture and address each by its UV rectangle, so objects share a material and batch into few draw calls. Pad to stop bleeding. - Category: Performance › GPU - Level: common - Tags: sprites, uv, packing, batching, mipmaps - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/gpu/texture-atlas ## What it is A **texture atlas** (sprite sheet) is one large texture holding many small images. Each image is addressed by its **UV rectangle** `(u0, v0) – (u1, v1)` instead of by its own texture. Objects that used to need different textures can now share one material, which is what lets them batch or instance into a handful of [draw calls](https://gdcs.coody.app/performance/gpu/draw-calls) and avoids texture-binding state changes. Packing is a 2D bin-packing problem. A **shelf packer** sorts sprites by height and fills rows left to right. It is simple, fast and good enough for most atlases (often 70–85% full). Guillotine and MaxRects packers squeeze more in for offline tools. Atlases are usually power-of-two squares (1024, 2048, 4096) for the widest mipmap and compression support. Neighbouring sprites **bleed** into each other because linear filtering samples a 2×2 texel neighbourhood and mipmaps average whole blocks. Leave **padding** around each sprite, fill it by extruding the sprite's edge pixels, and keep UVs on the sprite's own pixels. Each mip level halves the gutter, so 2 px of padding protects about one mip level. More levels need more padding or a capped `maxLevel`. Texture arrays (`DataArrayTexture` in Three.js) are an alternative when every sprite is the same size: layers never bleed and each keeps full mipmaps. ## Key formulas - **Pixel rect → UV (flipY texture):** $u_0 = \frac{x}{S},\; u_1 = \frac{x+w}{S},\; v_0 = 1-\frac{y+h}{S},\; v_1 = 1-\frac{y}{S}$ — S is the atlas size in pixels; (x, y) is the sprite's top-left with y pointing down. - **Remap a mesh's 0–1 UVs into the rect:** $uv' = uv_0 + uv \odot (uv_1 - uv_0)$ ## Rules of thumb - Use 2–4 px of padding per side for filtered, mipmapped sprites; 1 px is enough for nearest-filtered pixel art without mipmaps. - Fill padding by extruding edge pixels, not with transparent or black pixels. - Group sprites that appear together into the same atlas (one per level or UI screen) so you don't load unused images. - Keep atlases ≤ 4096² for broad device support, and check `renderer.capabilities.maxTextureSize`. - Disable mipmaps or clamp `maxLevel` for UI atlases that are always drawn at 1:1. ## Implementation Shelf packing plus UV rects for a sprite atlas: ```ts function packShelves(sizes: { w: number; h: number }[], S: number, pad = 2) { const order = sizes.map((s, id) => ({ ...s, id })).sort((a, b) => b.h - a.h) const rects = new Array(sizes.length) let x = 0, y = 0, shelfH = 0 for (const s of order) { const w = s.w + 2 * pad, h = s.h + 2 * pad if (x + w > S) { y += shelfH; x = 0; shelfH = 0 } // next shelf if (y + h > S || w > S) return null // doesn't fit rects[s.id] = { x: x + pad, y: y + pad, w: s.w, h: s.h } x += w shelfH = Math.max(shelfH, h) } return rects } // UV rect for a Three.js texture (flipY = true): v grows upward. const uv = (r: { x: number; y: number; w: number; h: number }, S: number) => ({ u0: r.x / S, u1: (r.x + r.w) / S, v0: 1 - (r.y + r.h) / S, v1: 1 - r.y / S, }) ``` ## Common mistakes - No padding, so mipmapped sprites show thin lines of their neighbours' colours at their edges. - Forgetting that image textures are flipped in Y, so every UV rect samples the wrong row. - Packing unrelated sprites together, which forces loading a 4K texture for one icon. - Repeating (tiling) a sprite inside an atlas with `RepeatWrapping`. Wrap modes apply to the whole atlas, so tile in the shader with `fract()` instead. ## Interactive visual Left: sprites in the game world, drawn as one mesh that samples the atlas on the right. The highlighted sprite and its UV rectangle are outlined in white, and the faint border around each tile is its padding. Change the padding and sprite count to see the packer refill the shelves. ## Prompt ```text Write a texture-atlas builder in TypeScript for Three.js. Input: an array of HTMLImageElement or ImageBitmap with ids. Pack them with a shelf packer (sort by height, rows left to right) into the smallest power-of-two square up to 4096, with configurable padding filled by extruding each sprite's edge pixels on an OffscreenCanvas. Output a CanvasTexture (flipY true, sRGB colour space) and a map from id to UV rect {u0, v0, u1, v1}. Provide a helper that remaps a PlaneGeometry's uv attribute into a given rect. Return null if the sprites can't fit. Write tests: padded rects never overlap, all lie inside the atlas, and UVs stay inside 0–1. ``` ## Related - [Draw Calls](https://gdcs.coody.app/performance/gpu/draw-calls.md) — Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info. - [Overdraw](https://gdcs.coody.app/performance/gpu/overdraw.md) — Overdraw is shading the same pixel more than once. Stacked transparent particles and big quads multiply fill cost; trim sprites and limit layers. - [Frame Budget](https://gdcs.coody.app/performance/cpu/frame-budget.md) — At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. ## Further reading - [Three.js docs — Texture (offset, repeat, flipY)](https://threejs.org/docs/#api/en/textures/Texture) - [Khronos OpenGL wiki — Texture (filtering and mipmaps)](https://www.khronos.org/opengl/wiki/Texture) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Chunk Streaming > Split a big world into chunks, load those within a radius of the player and unload them past a larger radius, so the boundary never thrashes. - Category: Performance › World - Level: advanced - Tags: open world, streaming, hysteresis, chunks, memory - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/world/chunk-streaming ## What it is Large worlds don't fit in memory or in the [frame budget](https://gdcs.coody.app/performance/cpu/frame-budget) all at once. **Chunk streaming** divides the world into fixed-size squares (or cubes) and keeps only the ones near the player resident: terrain meshes, props, colliders, AI. Chunks are addressed by integer coordinates `(⌊x/s⌋, ⌊z/s⌋)`, so finding the neighbourhood is simple arithmetic. Use **two radii**. Load a chunk when it comes within `R_load` and unload it only after it moves beyond `R_unload > R_load`. This **hysteresis** band stops a chunk on the edge from being loaded and destroyed every time the player steps back and forth across it, which would waste I/O and cause hitches. A margin of about one chunk is typical. Loading takes time, so stream **nearest first** and spread work across frames: limit loads (and mesh building) per frame like [time slicing](https://gdcs.coody.app/performance/cpu/time-slicing), parse data in a Web Worker, and reuse released chunk objects from a pool. Set `R_load` large enough that a chunk finishes loading before the player can see or reach it: at least speed × worst-case load time beyond the view distance. Spatial queries inside resident chunks use the same tricks as [spatial partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning); chunks are a coarse grid around a finer one. ## Key formulas - **Chunk of a position:** $(c_x, c_z) = \left(\left\lfloor \tfrac{x}{s} \right\rfloor, \left\lfloor \tfrac{z}{s} \right\rfloor\right)$ - **Hysteresis:** $\text{load if } d \le R_{\text{load}}, \quad \text{unload if } d > R_{\text{unload}}, \quad R_{\text{unload}} > R_{\text{load}}$ - **Resident chunks (approx.):** $N \approx \frac{\pi R^2}{s^2}, \quad R_{\text{load}} \le R \le R_{\text{unload}}$ - **Minimum load radius:** $R_{\text{load}} \ge R_{\text{view}} + v_{\max}\, t_{\text{load}}$ ## Rules of thumb - Set the unload radius 1–2 chunk sizes larger than the load radius. - Load nearest first, with a per-frame cap (e.g. 1–4 chunk loads or mesh builds per frame). - Pick a chunk size that loads in a few milliseconds: commonly 16–64 m for terrain or 16³ voxels. - Pool chunk objects and GPU buffers. Re-fill them rather than creating and disposing each time. - Call `geometry.dispose()`, `material.dispose()` and `texture.dispose()` on chunks you truly release, or GPU memory grows forever. ## Implementation Load/unload with hysteresis and a per-frame budget: ```ts const S = 32, R_LOAD = 96, R_UNLOAD = 128, MAX_LOADS = 2 const resident = new Map() const dist = (cx: number, cz: number, p: THREE.Vector3) => Math.hypot((cx + 0.5) * S - p.x, (cz + 0.5) * S - p.z) function stream(player: THREE.Vector3) { for (const [key, chunk] of resident) if (dist(chunk.cx, chunk.cz, player) > R_UNLOAD) { chunk.release() // back to the pool, dispose GPU data if needed resident.delete(key) } const wanted: [number, number, number][] = [] const r = Math.ceil(R_LOAD / S) const pcx = Math.floor(player.x / S), pcz = Math.floor(player.z / S) for (let cx = pcx - r; cx <= pcx + r; cx++) for (let cz = pcz - r; cz <= pcz + r; cz++) { const d = dist(cx, cz, player) if (d <= R_LOAD && !resident.has(`${cx},${cz}`)) wanted.push([d, cx, cz]) } wanted.sort((a, b) => a[0] - b[0]) // nearest first for (const [, cx, cz] of wanted.slice(0, MAX_LOADS)) resident.set(`${cx},${cz}`, Chunk.acquire(cx, cz)) // async fill } ``` ## Common mistakes - Using one radius for both loading and unloading, so boundary chunks thrash as the player wiggles. - Loading every newly needed chunk in the same frame after a teleport, causing a long hitch. Use a budget and a loading screen for big jumps. - Forgetting to dispose released geometry and textures, so GPU memory leaks over a long session. - Making the load radius smaller than the view distance plus travel during load time, so the world pops in visibly. - Using floating-point world positions far from the origin. Rebase the origin when the player travels thousands of units. ## Interactive visual A top-down world of chunks around the orange player: green chunks are resident, yellow ones are waiting for the load budget, and red ones were just unloaded. The green ring is the load radius and the red ring the unload radius. Set the margin to 0 to see edge chunks thrash, and lower the budget to see loading lag behind. ## Prompt ```text Build a chunk streaming system in TypeScript for a Three.js open world on the x/z plane. Inputs: chunk size, loadRadius, unloadRadius (assert unload ≥ load), and maxLoadsPerFrame. Each frame: unload resident chunks whose centre is farther than unloadRadius (return them to a pool and dispose GPU resources only when the pool is full), then collect missing chunks within loadRadius, sort nearest first, and start at most maxLoadsPerFrame async loads, ignoring duplicates already in flight and cancelling loads that leave the unload radius before finishing. Expose events for loaded/unloaded. Write tests: wiggling across a chunk boundary produces zero load/unload events with a margin, and many without one. ``` ## Related - [Spatial Partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning.md) — Bin objects into a uniform grid so collision and proximity queries test only same-cell and neighbour-cell pairs instead of all n(n−1)/2 pairs. - [Time Slicing](https://gdcs.coody.app/performance/cpu/time-slicing.md) — Split expensive per-entity work into buckets and run one bucket per frame. Cost per frame drops by the bucket count; each entity reacts a little later. - [Frame Budget](https://gdcs.coody.app/performance/cpu/frame-budget.md) — At 60 Hz a frame has 16.67 ms for input, gameplay, physics, AI and rendering combined. Measure each system and keep the total under budget with headroom. ## Further reading - [Red Blob Games — Grids and graphs](https://www.redblobgames.com/pathfinding/grids/graphs.html) - [Three.js manual — How to dispose of objects](https://threejs.org/manual/#en/how-to-dispose-of-objects) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Spatial Partitioning > Bin objects into a uniform grid so collision and proximity queries test only same-cell and neighbour-cell pairs instead of all n(n−1)/2 pairs. - Category: Performance › World - Level: core - Tags: broad phase, uniform grid, spatial hash, quadtree, collision - Updated: 2026-11-01 - URL: https://gdcs.coody.app/performance/world/spatial-partitioning ## What it is Testing every object against every other is `n(n−1)/2` checks: 4,950 for 100 objects, about 500,000 for 1,000. Most of those pairs are far apart. **Spatial partitioning** organises objects by position so a query only looks at objects nearby. This is the **broad phase**: it produces a short list of candidate pairs for the exact (narrow-phase) test. The simplest structure is a **uniform grid**. Pick a cell size at least as large as the biggest object's diameter, put each object in the cell containing its centre, and test each object only against its own cell and the 8 neighbours. Any two overlapping objects are guaranteed to be in the same or adjacent cells. To count each pair once, scan each cell against itself and only its 4 "forward" neighbours. Rebuild the grid every frame with a counting sort (count per cell, prefix sum, scatter) into flat arrays. That costs `O(n + cells)` and allocates nothing. For unbounded worlds, hash `(cx, cy)` into a fixed table (a **spatial hash**) instead of allocating a dense array. Uniform grids suit objects of similar size. Very mixed sizes favour **quadtrees/octrees** or a **BVH** (bounding-volume hierarchy, also used for [raycasting](https://gdcs.coody.app/threejs/rendering/raycasting) against meshes), and sweep-and-prune works well when objects move little between frames. The same grid answers "who is within radius r" for AI and [time-sliced](https://gdcs.coody.app/performance/cpu/time-slicing) sensors. ## Key formulas - **Brute force:** $\text{checks} = \frac{n(n-1)}{2} = O(n^2)$ - **Uniform grid (evenly spread):** $\text{checks} \approx \frac{n(n-1)}{2}\cdot\frac{9c^2}{A}$ — Cell size c, world area A: roughly n × (objects in a 3×3 block of cells) / 2. - **Cell size rule:** $c \ge 2\,r_{\max}$ ## Rules of thumb - Start with cell size = 1–2× the largest object diameter. Much smaller cells miss overlaps (or need multi-cell insertion); much larger ones test too many pairs. - Aim for about 1–4 objects per occupied cell. - Rebuild from scratch each frame for moving objects; it is usually faster than incremental updates. - Insert a large object into every cell its bounds overlap, or keep giants in a separate list. - Use flat typed arrays (counts, starts, items) instead of arrays of arrays to avoid GC churn. ## Implementation Uniform grid broad phase with a counting sort and forward-neighbour scan: ```ts const FORWARD = [[1, 0], [-1, 1], [0, 1], [1, 1]] function broadPhase(xs: Float32Array, ys: Float32Array, n: number, cell: number, cols: number, rows: number, visit: (i: number, j: number) => void) { // Allocated here for brevity; keep these arrays across frames in real code. const cellOf = new Int32Array(n), start = new Int32Array(cols * rows + 1) for (let i = 0; i < n; i++) { const cx = Math.min(cols - 1, Math.max(0, Math.floor(xs[i] / cell))) const cy = Math.min(rows - 1, Math.max(0, Math.floor(ys[i] / cell))) cellOf[i] = cy * cols + cx start[cellOf[i] + 1]++ } for (let c = 0; c < cols * rows; c++) start[c + 1] += start[c] // prefix sum const fill = start.slice(0, -1), items = new Int32Array(n) for (let i = 0; i < n; i++) items[fill[cellOf[i]]++] = i for (let cy = 0; cy < rows; cy++) for (let cx = 0; cx < cols; cx++) { const a = cy * cols + cx for (let p = start[a]; p < start[a + 1]; p++) { for (let q = p + 1; q < start[a + 1]; q++) visit(items[p], items[q]) for (const [dx, dy] of FORWARD) { const nx = cx + dx, ny = cy + dy if (nx < 0 || nx >= cols || ny >= rows) continue const b = ny * cols + nx for (let q = start[b]; q < start[b + 1]; q++) visit(items[p], items[q]) } } } } ``` ## Common mistakes - Choosing cells smaller than the objects, so overlapping pairs in non-adjacent cells are silently missed. - Scanning all 8 neighbours from every cell and reporting each pair twice. - Allocating a new array per cell every frame and feeding the garbage collector. - Using a uniform grid for a world with a few huge objects and many tiny ones, where every cell ends up crowded. ## Interactive visual Moving circles on a uniform grid seen from above: lit cells are occupied (brighter with 2+ circles), faint white lines join each candidate pair the grid actually tests (same or neighbouring cell), and circles turn from green to red when they really overlap. Compare the readout's brute-force count with the grid's, then change the cell size or circle count. ## Prompt ```text Implement a uniform-grid broad phase in TypeScript for circles in a 2D (x/z) Three.js game. Constructor takes world bounds, cell size and capacity and preallocates Int32Arrays for counts, cell starts and sorted items. build(circles) bins by centre with a counting sort, clamps out-of-bounds points to edge cells, and throws if any diameter exceeds the cell size. candidates(visit) reports each unordered pair from the same cell and the 4 forward neighbours exactly once and returns the number of checks. Add queryRadius(x, y, r, out) for AI. Write tests: on random data the grid's overlapping pairs equal brute force exactly, with no duplicates, and use far fewer checks. ``` ## Related - [Chunk Streaming](https://gdcs.coody.app/performance/world/chunk-streaming.md) — Split a big world into chunks, load those within a radius of the player and unload them past a larger radius, so the boundary never thrashes. - [Time Slicing](https://gdcs.coody.app/performance/cpu/time-slicing.md) — Split expensive per-entity work into buckets and run one bucket per frame. Cost per frame drops by the bucket count; each entity reacts a little later. - [Raycasting](https://gdcs.coody.app/threejs/rendering/raycasting.md) — A ray is an origin plus a direction. Raycasting returns the nearest surface it crosses, with hit point, distance and surface normal. ## Further reading - [Red Blob Games — Grids and graphs](https://www.redblobgames.com/pathfinding/grids/graphs.html) - [GPU Gems 3 — Broad-phase collision detection with CUDA](https://developer.nvidia.com/gpugems/gpugems3/part-v-physics-simulation/chapter-32-broad-phase-collision-detection-cuda) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Balancing Act: Torque and Levers > A lever balances when m₁r₁ = m₂r₂. Torque is force times lever arm, and net torque over moment of inertia gives angular acceleration. - Category: Physics › Mechanics - Level: core - Tags: torque, levers, statics, rotation, moment-of-inertia - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/balancing-torque ## What it is **Torque** is the turning effect of a force: its size times its perpendicular distance from the pivot, `τ = r·F·sin φ`. A 2 kg box 3 m from a fulcrum turns a plank exactly as hard as a 3 kg box 2 m away on the other side, so the plank stays level. That is the law of the lever, `m₁r₁ = m₂r₂`. When the torques do not cancel, the plank gains angular velocity in the direction of the stronger side: `Στ = I·α`. The **moment of inertia** `I = Σ m r²` grows with the square of distance, so the same loads placed far out make the plank turn more sluggishly even when the net torque is the same. Games meet torque in see-saw puzzles, physics-based cranes, ragdolls and vehicles. In a rigid-body engine you rarely compute it yourself: call `applyForceAtPoint` and the engine adds `r × F` to the body's torque. Understanding it tells you why a heavy object placed off-centre tips a platform, why long arms feel slow to swing, and how far to put a counterweight. ## Key formulas - **Torque (vector form):** $\vec\tau = \vec r \times \vec F$ - **Lever balance:** $m_1 g\, r_1 = m_2 g\, r_2 \;\Rightarrow\; m_1 r_1 = m_2 r_2$ - **Rotational Newton's law:** $\sum \tau = I\alpha$ - **Moment of inertia (plank + point loads):** $I = \tfrac{1}{12} M L^2 + \sum m_i r_i^2$ ## Rules of thumb - Static equilibrium needs both ΣF = 0 and Στ = 0 about any point; pick the pivot as that point so the support force drops out. - Only the component of the force perpendicular to the arm counts: a weight on a plank tilted by θ has lever arm `r·cos θ`. - Doubling a load's distance doubles its torque but quadruples its contribution to inertia. - To balance a load W at distance r with a counterweight C, place C at `W·r / C` on the other side. ## Implementation Net torque and angular step for a see-saw (fixed timestep): ```ts type Load = { mass: number; x: number } // x: signed distance from pivot (m) export function stepSeesaw( s: { angle: number; omega: number }, loads: Load[], plankMass: number, plankLength: number, dt: number, g = 9.81, damping = 1.5 ) { let torque = 0 let inertia = (plankMass * plankLength ** 2) / 12 for (const l of loads) { torque -= l.mass * g * l.x * Math.cos(s.angle) // CCW positive; right side pushes CW inertia += l.mass * l.x * l.x } s.omega += (torque / inertia - damping * s.omega) * dt s.angle += s.omega * dt s.angle = Math.max(-0.45, Math.min(0.45, s.angle)) // ground stops } ``` ## Common mistakes - Comparing masses instead of torques: a light load far out can beat a heavy one close in. - Using the distance along the tilted plank without the `cos θ` factor once it rotates. - Forgetting the plank's own inertia, which makes an empty see-saw spin unrealistically fast. - Applying a force at the centre of mass in the engine and wondering why the object never rotates; use the force-at-point call. ## Interactive visual The cyan (left) and orange (right) loads sit on a grey plank with 1 m ticks; yellow arrows are their weights. The defaults balance (3 kg × 2 m = 2 kg × 3 m), so the plank stays level. Change any mass or distance and it tips toward the larger torque. ## Prompt ```text Write a TypeScript see-saw puzzle module for a Three.js game. Inputs: plank length and mass, a list of loads {mass, x} where x is signed distance from the fulcrum, and a fixed timestep. Compute net torque Σ(−m·g·x·cos θ), moment of inertia ML²/12 + Σ m x², integrate angle with semi-implicit Euler plus light damping, and clamp at ±25° ground stops. Expose isBalanced(tolerance) and counterweightPosition(mass). Unit tests: 3 kg at −2 m and 2 kg at +3 m is balanced; moving the 2 kg load to +3.5 m makes the plank rotate clockwise. ``` ## Related - [Buoyancy](https://gdcs.coody.app/physics/mechanics/buoyancy.md) — Archimedes: a body is pushed up by the weight of fluid it displaces, so it floats with ρ_object/ρ_fluid of its volume under. Boats and crates in games. - [Spring-Mass Oscillator](https://gdcs.coody.app/physics/mechanics/spring-mass-oscillator.md) — Hooke's law, damping and a periodic push: the forced oscillator behind springy cameras, wobbly UI and suspension, and why driving at ω₀ explodes. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [OpenStax University Physics — Torque](https://openstax.org/books/university-physics-volume-1/pages/10-6-torque) - [OpenStax University Physics — Conditions for Static Equilibrium](https://openstax.org/books/university-physics-volume-1/pages/12-1-conditions-for-static-equilibrium) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Buoyancy > Archimedes: a body is pushed up by the weight of fluid it displaces, so it floats with ρ_object/ρ_fluid of its volume under. Boats and crates in games. - Category: Physics › Mechanics - Level: core - Tags: fluids, archimedes, density, floating, water - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/buoyancy ## What it is Water pressure grows with depth, so the bottom of a submerged object is pushed up harder than its top is pushed down. The net upward push, the **buoyant force**, equals the weight of the fluid the object displaces: `F_b = ρ_fluid · g · V_submerged`. That is Archimedes' principle. Compare it with the weight `ρ_object · g · V`. If the object is less dense than the fluid it sinks only until the displaced fluid weighs as much as it does, so it floats with a fraction `ρ_object/ρ_fluid` of its volume under the surface (ice: about 90%). Denser objects keep sinking until the floor supplies the missing force. Equal densities are **neutrally buoyant** and hover wherever they are. Game water uses the same idea per sample point: split a hull into a few probe points, measure how deep each one is below the wave height, and push up at that point in proportion to depth. Off-centre buoyancy creates [torque](https://gdcs.coody.app/physics/mechanics/balancing-torque), which is what makes boats roll, pitch and right themselves. Add drag that scales with the submerged fraction or the object bobs forever. ## Key formulas - **Buoyant force:** $F_b = \rho_{\text{fluid}}\, g\, V_{\text{sub}}$ - **Weight:** $F_g = \rho_{\text{obj}}\, g\, V$ - **Floating fraction:** $\dfrac{V_{\text{sub}}}{V} = \dfrac{\rho_{\text{obj}}}{\rho_{\text{fluid}}}$ - **Vertical motion (simulated here):** $\ddot y = g\left(\dfrac{\rho_{\text{fluid}}}{\rho_{\text{obj}}}\,f(y) - 1\right) - c\,f(y)\,\dot y$ — f(y) is the submerged fraction (0–1); c is water drag. ## Rules of thumb - Densities (kg/m³): fresh water 1000, sea water ~1025, oak ~700, pine ~500, ice ~917, steel ~7850, human ~985. - Use 4–8 probe points on a boat hull; one point floats but never tilts. - Linear water drag of 1–3 s⁻¹ times the submerged fraction settles bobbing in a couple of seconds without looking syrupy. - Clamp per-point depth to the hull height so a deep dive does not produce unbounded lift. ## Implementation Probe-point buoyancy for a rigid body (engine-neutral): ```ts type Vec3 = { x: number; y: number; z: number } export function applyBuoyancy( body: { applyForceAtPoint(f: Vec3, p: Vec3): void; velocityAt(p: Vec3): Vec3 }, probes: Vec3[], // world-space hull points waterHeight: (x: number, z: number) => number, volume: number, // body volume (m³) probeHeight: number, // vertical extent each probe represents (m) rhoFluid = 1000, g = 9.81, drag = 2 ) { const share = volume / probes.length for (const p of probes) { const depth = waterHeight(p.x, p.z) - p.y if (depth <= 0) continue const f = Math.min(1, depth / probeHeight) // submerged fraction for this probe const v = body.velocityAt(p) const lift = rhoFluid * g * share * f const damp = rhoFluid * share * f * drag body.applyForceAtPoint({ x: -damp * v.x, y: lift - damp * v.y, z: -damp * v.z }, p) } } ``` ## Common mistakes - Applying the full buoyant force as soon as an object touches the surface instead of scaling it by the submerged fraction: objects pop out of the water. - Pushing at the centre of mass only, so boats never roll or self-right. - No drag in the water, so a dropped crate bobs up and down forever. - Using real densities with a game mass that does not match the mesh volume: the float line ends up wrong. ## Interactive visual The orange block drops into the cyan tank and settles. The cyan arrow is the buoyant force (it grows with how much is under water), the yellow arrow is the weight, and a green arrow appears when the floor has to hold up a block that sinks. Raise the object density above the fluid density to sink it. ## Prompt ```text Implement buoyancy for floating crates in a TypeScript Three.js game with a fixed 1/120 s step. Inputs: crate size, density, fluid density, a waterHeight(x, z) function, gravity and water drag. Compute the submerged fraction of the box, apply F = ρ_fluid·g·V·f upward and drag proportional to f·velocity, integrate with semi-implicit Euler, and stop on the tank floor. Then extend it to 4 corner probes so the crate tilts on waves. Unit tests: a 650 kg/m³ crate in water settles with 65% of its height submerged; a 1500 kg/m³ crate ends on the floor; equal densities produce no net force when fully submerged. ``` ## Related - [Balancing Act: Torque and Levers](https://gdcs.coody.app/physics/mechanics/balancing-torque.md) — A lever balances when m₁r₁ = m₂r₂. Torque is force times lever arm, and net torque over moment of inertia gives angular acceleration. - [Spring-Mass Oscillator](https://gdcs.coody.app/physics/mechanics/spring-mass-oscillator.md) — Hooke's law, damping and a periodic push: the forced oscillator behind springy cameras, wobbly UI and suspension, and why driving at ω₀ explodes. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [OpenStax University Physics — Archimedes' Principle and Buoyancy](https://openstax.org/books/university-physics-volume-1/pages/14-4-archimedes-principle-and-buoyancy) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Damped Pendulum > A swinging weight pulled by sin θ: its period is only 2π√(L/g) for small swings, and damping decays the arc. Grapples, lamps and ropes in games. - Category: Physics › Mechanics - Level: common - Tags: pendulum, oscillation, damping, nonlinear, integration - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/damped-pendulum ## What it is A pendulum is a weight on a rigid rod or taut string. Gravity's component along the arc is `−g·sin θ`, so the angular equation is `θ'' = −(g/L)·sin θ`. Only for small angles, where `sin θ ≈ θ`, does it become a simple harmonic oscillator with period `2π√(L/g)` that is independent of amplitude. Release it from 60° and the real period is about 7% longer than that formula: the restoring force near the top is weaker than the linear model assumes, so a wide swing lags. A damping term `−b·θ'` (air drag, pivot friction) makes the amplitude shrink by `e^(−bt/2)`. In games the same equation drives grappling hooks, swinging lamps, chains, hanging signs and rope bridges. Integrate the full `sin θ` version in a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep) loop; the small-angle shortcut breaks down exactly in the big, dramatic swings players notice. ## Key formulas - **Equation of motion:** $\ddot{\theta} = -\dfrac{g}{L}\sin\theta - b\,\dot{\theta}$ - **Small-angle period:** $T_0 = 2\pi\sqrt{L/g}$ - **Large-angle correction:** $T \approx T_0\left(1 + \dfrac{\theta_0^2}{16} + \dfrac{11\,\theta_0^4}{3072}\right)$ — θ₀ in radians; within 0.1% up to about 70°. - **Amplitude decay (light damping):** $\theta_{\max}(t) \approx \theta_0\, e^{-bt/2}$ ## Rules of thumb - A 1 m pendulum on Earth ticks about once per second each way (T ≈ 2.0 s). - The period scales with √L: four times the rope length doubles the swing time. Mass does not appear at all. - Simulate the angle, not x/y, for a rigid rod; for a rope that can go slack, use a Verlet particle with a distance constraint instead. - Use semi-implicit Euler at 120–240 Hz; explicit Euler makes an undamped pendulum swing higher every cycle. ## Implementation A swinging lamp: integrate θ, then place the mesh (Three.js): ```ts import * as THREE from "three" export function makePendulum(pivot: THREE.Vector3, length: number, theta0: number) { let theta = theta0 let omega = 0 return { /** Fixed-step update; b is damping in 1/s. */ step(dt: number, g = 9.81, b = 0.2) { omega += (-(g / length) * Math.sin(theta) - b * omega) * dt theta += omega * dt }, place(bob: THREE.Object3D) { bob.position.set( pivot.x + length * Math.sin(theta), pivot.y - length * Math.cos(theta), pivot.z ) }, push(impulse: number) { omega += impulse / length }, // e.g. on hit } } ``` ## Common mistakes - Using `θ` instead of `sin θ` for big swings: the motion looks too regular and too fast. - Integrating with explicit Euler: with zero damping the amplitude grows until the pendulum loops over the top. - Expressing damping per frame (`omega *= 0.99`), which makes the decay rate depend on frame rate. Use `exp(−b·dt)` or the `−b·ω` term. - Mixing degrees and radians in `Math.sin`. ## Interactive visual The orange bob is the full nonlinear pendulum; the faint cyan ghost follows the small-angle (harmonic) model from the same release angle. At 60° the ghost pulls ahead each swing; drop the release angle below 15° and they move together. Damping shrinks both arcs inside the grey release arc. ## Prompt ```text Implement a damped pendulum for a Three.js scene in TypeScript. State: angle θ (radians) and angular velocity ω. Each fixed 1/240 s step apply semi-implicit Euler to θ'' = −(g/L)·sin θ − b·θ'. Expose step(dt), reset(θ0), applyImpulse(j) and bobPosition(out: THREE.Vector3) relative to a pivot. Handle θ0 up to 179° and L > 0 validation. Write unit tests: for θ0 = 0.01 rad and b = 0 the measured period matches 2π√(L/g) within 0.5%, and for θ0 = 60° it is about 7% longer. ``` ## Related - [Spring-Mass Oscillator](https://gdcs.coody.app/physics/mechanics/spring-mass-oscillator.md) — Hooke's law, damping and a periodic push: the forced oscillator behind springy cameras, wobbly UI and suspension, and why driving at ω₀ explodes. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. - [Projectile Motion](https://gdcs.coody.app/physics/mechanics/projectile-motion.md) — Launch angle, speed and gravity fully determine a ballistic arc: x moves at constant speed while y accelerates downward. ## Further reading - [OpenStax University Physics — Pendulums](https://openstax.org/books/university-physics-volume-1/pages/15-4-pendulums) - [OpenStax University Physics — Damped Oscillations](https://openstax.org/books/university-physics-volume-1/pages/15-5-damped-oscillations) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Kepler's Laws > Orbits are ellipses with the star at one focus, sweep equal areas in equal times, and have T² ∝ a³. Place planets on rails exactly with Kepler's equation. - Category: Physics › Mechanics - Level: common - Tags: orbits, gravity, kepler, astronomy, on-rails - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/keplers-laws ## What it is Kepler's three laws describe any two-body orbit under inverse-square gravity. **One:** the path is an ellipse with the star at a focus (not the centre). **Two:** the line from star to planet sweeps equal areas in equal times, so the planet races through perihelion and dawdles at aphelion. **Three:** the period squared is proportional to the semi-major axis cubed. The second law makes "where is the planet at time t?" a puzzle: angle does not grow linearly. The trick is the **mean anomaly** `M = n·t`, which does grow linearly, and Kepler's equation `M = E − e·sin E` that converts it into the **eccentric anomaly** `E`. From `E` the position is just `(a·cos E, b·sin E)` measured from the ellipse centre. There is no closed-form inverse, so you solve it with a few Newton iterations. For games this is the right way to put planets, moons and comets **on rails**: positions are exact for any time, never drift, and can be scrubbed forwards or backwards (time warp, save/load, multiplayer sync). Switch to numerical [n-body integration](https://gdcs.coody.app/physics/mechanics/solar-system) only for objects that thrust or interact. ## Key formulas - **Orbit (focus at origin):** $r(\nu) = \dfrac{a(1-e^2)}{1 + e\cos\nu}$ - **Kepler's equation:** $M = n t = E - e \sin E$ — Solve for E with Newton: E ← E − (E − e sin E − M)/(1 − e cos E). - **Position from the ellipse centre:** $x = a\cos E,\quad y = b\sin E,\quad b = a\sqrt{1-e^2}$ - **Third law:** $T^2 = \dfrac{4\pi^2 a^3}{G M}$ - **Speed ratio:** $\dfrac{v_{\text{peri}}}{v_{\text{apo}}} = \dfrac{1+e}{1-e}$ ## Rules of thumb - Start Newton at `E = M` for e ≤ 0.8 and at `E = π` above that; 5–10 iterations reach double precision. - Wrap `M` into [−π, π] before solving so large times do not lose precision. - Mean motion `n = 2π/T`. Earth: e ≈ 0.017 (nearly circular); Halley's comet: e ≈ 0.97. - The star sits at `(a·e, 0)` from the ellipse centre; the empty second focus is at `(−a·e, 0)`. ## Implementation Planet on rails: exact position for any time (Three.js): ```ts import * as THREE from "three" export function solveKepler(M: number, e: number) { M = Math.atan2(Math.sin(M), Math.cos(M)) // wrap to [-π, π] let E = e > 0.8 ? Math.sign(M || 1) * Math.PI : M for (let i = 0; i < 10; i++) { E -= (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E)) } return E } /** Position relative to the star (focus) at time t. */ export function orbitPosition(a: number, e: number, period: number, t: number, out: THREE.Vector3) { const E = solveKepler((2 * Math.PI * t) / period, e) const b = a * Math.sqrt(1 - e * e) return out.set(a * (Math.cos(E) - e), 0, b * Math.sin(E)) } ``` ## Common mistakes - Advancing the true angle at a constant rate: the planet moves at the same speed everywhere and the second law is violated. - Putting the star at the ellipse centre instead of a focus. - Newton from `E = M` at high eccentricity can overshoot and oscillate; start at π or bisect first. - Integrating orbits with explicit Euler: the planet spirals outward within a few revolutions. Use rails, or a symplectic integrator. ## Interactive visual The yellow star sits at one focus and the orange planet moves along the grey ellipse; the yellow line is its velocity. The cyan and purple wedges each take one eighth of the period, and they all have the same area. Raise the eccentricity to see short, wide wedges swept quickly near the star and long, narrow ones swept slowly far away. ## Prompt ```text Write a TypeScript module for a Three.js space game that places bodies on Kepler orbits. Inputs: semi-major axis a, eccentricity e in [0, 0.99), period T, argument of periapsis ω, and time t. Solve Kepler's equation M = E − e·sin E with Newton (start at π when e > 0.8, wrap M to [−π, π], stop when |Δ| < 1e-12 or 15 iterations), return position relative to the focus rotated by ω, and the velocity vector. Provide orbitLine(samples) for drawing with THREE.Line. Unit tests: residual |E − e sin E − M| < 1e-9 for e up to 0.95, and the area swept from the focus over four equal time slices is equal. ``` ## Related - [Solar System: Two-Body Orbits](https://gdcs.coody.app/physics/mechanics/solar-system.md) — Two gravitating bodies circle their shared barycentre: radii split by mass, period set by total mass. Plus a stable n-body integrator for games. - [Projectile Motion](https://gdcs.coody.app/physics/mechanics/projectile-motion.md) — Launch angle, speed and gravity fully determine a ballistic arc: x moves at constant speed while y accelerates downward. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [OpenStax University Physics — Kepler's Laws of Planetary Motion](https://openstax.org/books/university-physics-volume-1/pages/13-5-keplers-laws-of-planetary-motion) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Projectile Data Lab > Repeat a launch with a little speed error and the landings form a distribution. Mean, spread and why R ∝ v² shifts the average: the maths of weapon spread. - Category: Physics › Mechanics - Level: common - Tags: statistics, ballistics, uncertainty, spread, sampling - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/projectile-data-lab ## What it is Real launches are never identical. If the launch speed varies by ±`p`% around a mean, every shot follows the [projectile](https://gdcs.coody.app/physics/mechanics/projectile-motion) range formula `R = v²·sin 2θ / g` with a slightly different `v`, and the landing points spread out along the ground. Collecting many trials and summarising them with a **mean** and **standard deviation** turns that scatter into two numbers you can design against. Because range grows with the **square** of speed, the error propagates nonlinearly: a +10% speed error adds about 21% range while −10% removes only 19%. The landings are skewed long, and the mean range is slightly greater than the range of the mean speed. For small errors the relative range spread is about twice the relative speed spread (`σ_R/R ≈ 2·σ_v/v`). This is exactly how weapon spread, AI aim error and throw variance behave in games. Choose the input noise (uniform, Gaussian, stratified) on purpose, check the resulting landing distribution with a quick histogram, and use a seeded or stratified sampler so tests and replays are deterministic. See also [seeded noise](https://gdcs.coody.app/mathematics/procedural/seeded-noise). ## Key formulas - **Range per trial:** $R_i = \dfrac{v_i^2 \sin 2\theta}{g}$ - **Sample mean:** $\bar R = \dfrac{1}{N}\sum_{i=1}^{N} R_i$ - **Standard deviation:** $\sigma = \sqrt{\dfrac{1}{N}\sum_{i=1}^{N}(R_i - \bar R)^2}$ - **Error propagation (small errors):** $\dfrac{\sigma_R}{R} \approx 2\,\dfrac{\sigma_v}{v}$ - **Mean shift from convexity:** $\mathbb{E}[R] = \dfrac{\sin 2\theta}{g}\left(\bar v^2 + \sigma_v^2\right)$ ## Rules of thumb - A uniform ±p% speed error has σ_v = p/√3 % of the mean, so ±10% gives σ_R ≈ 11.5% of range. - Need ~30 trials before a sample mean is trustworthy to a few percent; the standard error shrinks as `σ/√N`. - Stratified samples (one per quantile) give the same distribution every run and converge faster than pure random sampling. - At 45° range is insensitive to angle error (first-order zero), but fully sensitive to speed error. ## Implementation Deterministic landing spread and summary statistics: ```ts export function landingSpread(angleDeg: number, speed: number, spreadPct: number, runs: number, g = 9.81) { const s2 = Math.sin((2 * angleDeg * Math.PI) / 180) const ranges = Array.from({ length: runs }, (_, i) => { const q = (i + 0.5) / runs // stratified quantile in (0, 1) const v = speed * (1 + ((2 * q - 1) * spreadPct) / 100) return (v * v * s2) / g }) const mean = ranges.reduce((a, r) => a + r, 0) / runs const std = Math.sqrt(ranges.reduce((a, r) => a + (r - mean) ** 2, 0) / runs) return { ranges, mean, std } } // AI grenade: aim at target, then accept if (std / mean) keeps 90% of throws inside the blast radius. ``` ## Common mistakes - Assuming symmetric input noise gives a symmetric output: R ∝ v² skews the landings long. - Calling `Math.random()` inside gameplay code that must replay identically; use a seeded PRNG or stratified samples. - Reporting only the mean. Players feel the spread (σ, or the worst 5%), not the average. - Binning a histogram over [0, max] so all the data lands in two bars; bin over the samples' own min–max range. ## Interactive visual The cyan arc is the mean-speed launch and the grey arcs are the slowest and fastest; orange dots mark every landing and the purple bars are their histogram. The yellow line is the mean landing distance and the green dashed line the ideal range at mean speed: the yellow sits slightly beyond the green. Raise the speed error to widen the cluster. ## Prompt ```text Write a TypeScript helper for a Three.js artillery game that estimates landing spread. Inputs: launch angle (degrees), mean speed, speed error ±p%, trial count N, gravity g, and an optional seeded PRNG (otherwise use stratified quantiles (i+0.5)/N). Return ranges[], mean, standard deviation, min, max and a histogram(bins) over [min, max] with the last bin closed. Draw the trajectories with THREE.Line and the histogram as instanced boxes. Unit tests: zero error gives σ = 0; results are identical across runs; the mean range is greater than v̄²·sin2θ/g for any p > 0. ``` ## Related - [Projectile Motion](https://gdcs.coody.app/physics/mechanics/projectile-motion.md) — Launch angle, speed and gravity fully determine a ballistic arc: x moves at constant speed while y accelerates downward. - [Seeded Noise](https://gdcs.coody.app/mathematics/procedural/seeded-noise.md) — A seeded PRNG plus gradient noise and fBm turns one integer into an endless, smooth, repeatable world: same seed, same terrain on every machine. - [Particle System](https://gdcs.coody.app/systems/patterns/particle-system.md) — An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [OpenStax Introductory Statistics — Measures of the Spread of the Data](https://openstax.org/books/introductory-statistics/pages/2-7-measures-of-the-spread-of-the-data) - [OpenStax University Physics — Projectile Motion](https://openstax.org/books/university-physics-volume-1/pages/4-3-projectile-motion) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Projectile Motion > Launch angle, speed and gravity fully determine a ballistic arc: x moves at constant speed while y accelerates downward. - Category: Physics › Mechanics - Level: core - Tags: kinematics, gravity, ballistics, trajectory - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/projectile-motion ## What it is A projectile is any body moving under gravity alone. Split its velocity into components: the horizontal part `v0·cos θ` never changes (no drag), while the vertical part `v0·sin θ` loses `g` metres per second every second. Because the two axes are independent, the path is a parabola. Flight time, peak height and range all follow from the launch speed, angle and gravity, so you can predict a landing point before the shot happens. In games this is how grenades, arrows, jump arcs and aim-assist trajectory previews are computed. It is also the reference you check a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep) integrator against. ## Key formulas - **Horizontal position:** $x(t) = v_0 \cos\theta \, t$ - **Vertical position:** $y(t) = v_0 \sin\theta \, t - \tfrac{1}{2} g t^2$ - **Flight time (flat ground):** $T = \frac{2 v_0 \sin\theta}{g}$ - **Range:** $R = \frac{v_0^2 \sin 2\theta}{g}$ — Maximum at 45° on flat ground, with no drag. - **Peak height:** $H = \frac{(v_0 \sin\theta)^2}{2g}$ ## Rules of thumb - Complementary angles (30° and 60°) land at the same spot; the steeper one stays in the air longer. - Earth `g = 9.81 m/s²`. Many platformers use 2–3× that for snappier jumps. - To hit a target, solve the range equation for θ. Two solutions mean a low and a high lob. ## Implementation Sample an arc for a trajectory preview (Three.js): ```ts import * as THREE from "three" export function trajectory(v0: number, angle: number, g = 9.81, samples = 64) { const vx = v0 * Math.cos(angle) const vy = v0 * Math.sin(angle) const T = (2 * vy) / g const points: THREE.Vector3[] = [] for (let i = 0; i <= samples; i++) { const t = (i / samples) * T points.push(new THREE.Vector3(vx * t, vy * t - 0.5 * g * t * t, 0)) } return new THREE.BufferGeometry().setFromPoints(points) // draw with THREE.Line } ``` ## Common mistakes - Mixing degrees and radians: `Math.sin(45)` is not sin 45°. - Integrating with explicit Euler at a variable frame rate, which makes the landing point depend on FPS. - Forgetting launch height: the flat-ground formulas assume the projectile lands at the height it started from. - Adding drag later and still trusting the analytic range. Once there is drag, simulate numerically. ## Interactive visual The orange ball follows the cyan arc and the yellow line is its instantaneous velocity. Change the angle, launch speed and gravity to reshape the arc; the readout shows flight time and range. ## Prompt ```text Implement a projectile trajectory preview in TypeScript with Three.js. Given launch speed v0 (m/s), angle θ (radians) and gravity g, compute x(t)=v0·cosθ·t and y(t)=v0·sinθ·t−½gt², sample 64 points until it returns to launch height (T=2v0·sinθ/g), and draw them with THREE.Line. Also return range R=v0²·sin2θ/g and peak height H=(v0·sinθ)²/(2g). Keep angles in radians, make the sampler a pure function, and add a unit test checking that 30° and 60° give the same range. ``` ## Related - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Projectile Data Lab](https://gdcs.coody.app/physics/mechanics/projectile-data-lab.md) — Repeat a launch with a little speed error and the landings form a distribution. Mean, spread and why R ∝ v² shifts the average: the maths of weapon spread. - [Kepler's Laws](https://gdcs.coody.app/physics/mechanics/keplers-laws.md) — Orbits are ellipses with the star at one focus, sweep equal areas in equal times, and have T² ∝ a³. Place planets on rails exactly with Kepler's equation. ## Further reading - [OpenStax University Physics — Projectile Motion](https://openstax.org/books/university-physics-volume-1/pages/4-3-projectile-motion) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Solar System: Two-Body Orbits > Two gravitating bodies circle their shared barycentre: radii split by mass, period set by total mass. Plus a stable n-body integrator for games. - Category: Physics › Mechanics - Level: common - Tags: gravity, n-body, barycentre, orbits, integration - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/solar-system ## What it is Gravity is mutual: the star pulls the planet and the planet pulls the star with an equal and opposite force. With no outside forces the **centre of mass** (barycentre) never accelerates, so both bodies orbit that point, not each other's centres. The heavier body traces the smaller circle: `m₁·r₁ = m₂·r₂`. For a circular pair the separation `d` and total mass fix the angular rate through Kepler's third law: `ω² = G(m₁+m₂)/d³`. Make one body heavier and the whole system spins faster; make them equal and they chase each other around a point exactly halfway between. In a game with more than two bodies there is no closed form, so you integrate. Sum the pairwise forces, then advance with a symplectic integrator (leapfrog/velocity Verlet) at a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep). Add a softening length so close passes do not fling bodies to infinity. For background planets that never interact, [Kepler rails](https://gdcs.coody.app/physics/mechanics/keplers-laws) are cheaper and drift-free. ## Key formulas - **Newton's law of gravitation:** $\vec F_{ij} = G\,\dfrac{m_i m_j}{|\vec r_{ij}|^3}\,\vec r_{ij}$ - **Barycentre:** $\vec R_{cm} = \dfrac{\sum m_i \vec r_i}{\sum m_i}$ - **Radii around the barycentre:** $r_1 = \dfrac{m_2}{m_1+m_2}\,d,\quad r_2 = \dfrac{m_1}{m_1+m_2}\,d$ - **Angular rate of a circular pair:** $\omega = \sqrt{\dfrac{G(m_1+m_2)}{d^3}}$ - **Circular orbit speed (light satellite):** $v_c = \sqrt{GM/r}$ ## Rules of thumb - Pick game units so `G = 1` and masses/distances are near 1–100; real SI values lose float precision fast. - Softened gravity: use `|r|² + ε²` in the denominator with ε ≈ 1–5% of a typical separation. - Leapfrog/velocity Verlet keeps orbits closed for thousands of revolutions; explicit Euler spirals out within a few. - Start a stable circular orbit by giving the satellite `v = √(GM/r)` perpendicular to the radius, and give the star the opposite momentum so the barycentre stays put. - Sun–Jupiter's barycentre lies just outside the Sun's surface: about 1.07 solar radii from its centre. ## Implementation Velocity-Verlet n-body step with softening (Three.js vectors): ```ts import * as THREE from "three" type Body = { m: number; p: THREE.Vector3; v: THREE.Vector3; a: THREE.Vector3 } const d = new THREE.Vector3() function accelerations(bodies: Body[], G = 1, eps2 = 0.01) { for (const b of bodies) b.a.set(0, 0, 0) for (let i = 0; i < bodies.length; i++) for (let j = i + 1; j < bodies.length; j++) { const bi = bodies[i], bj = bodies[j] d.subVectors(bj.p, bi.p) const r2 = d.lengthSq() + eps2 const s = G / (r2 * Math.sqrt(r2)) bi.a.addScaledVector(d, s * bj.m) bj.a.addScaledVector(d, -s * bi.m) } } /** Call accelerations(bodies) once before the first step. */ export function step(bodies: Body[], dt: number) { for (const b of bodies) { b.v.addScaledVector(b.a, dt / 2) // half kick b.p.addScaledVector(b.v, dt) // drift } accelerations(bodies) for (const b of bodies) b.v.addScaledVector(b.a, dt / 2) } ``` ## Common mistakes - Pinning the star at the origin while the planet orbits it: momentum is not conserved and heavy moons look wrong. - Updating each body's position before computing the others' forces, so the result depends on array order. - Integrating with explicit Euler or a variable frame time: orbits gain energy and spiral outwards. - No softening: a near collision gives a huge acceleration and slingshots bodies out of the system. ## Interactive visual The cyan and orange bodies circle the yellow barycentre ring along their faint orbits; sphere size grows with mass at equal density. Raise the mass ratio and the orange body moves inward while the cyan one swings wide, and the whole pair speeds up. ## Prompt ```text Implement a TypeScript n-body gravity simulator for a Three.js scene. Inputs: an array of bodies {mass, position: THREE.Vector3, velocity: THREE.Vector3}, gravitational constant G, softening ε and a fixed timestep dt. Use velocity Verlet (half-kick, drift, recompute accelerations, half-kick), compute each pair once and apply equal and opposite forces, and never allocate inside step(). Provide totalEnergy() and barycentre(). Edge cases: zero or one body, coincident bodies (softening). Tests: a two-body circular orbit with v = √(G(m₁+m₂)/d) keeps its separation within 0.5% over 100 orbits, and the barycentre stays fixed to 1e-9. ``` ## Related - [Kepler's Laws](https://gdcs.coody.app/physics/mechanics/keplers-laws.md) — Orbits are ellipses with the star at one focus, sweep equal areas in equal times, and have T² ∝ a³. Place planets on rails exactly with Kepler's equation. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Projectile Motion](https://gdcs.coody.app/physics/mechanics/projectile-motion.md) — Launch angle, speed and gravity fully determine a ballistic arc: x moves at constant speed while y accelerates downward. ## Further reading - [OpenStax University Physics — Center of Mass](https://openstax.org/books/university-physics-volume-1/pages/9-6-center-of-mass) - [OpenStax University Physics — Satellite Orbits and Energy](https://openstax.org/books/university-physics-volume-1/pages/13-4-satellite-orbits-and-energy) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Sound Waves > Sound is a longitudinal pressure wave: frequency sets pitch and wavelength, amplitude sets loudness. The physics behind 3D audio, delay and Doppler. - Category: Physics › Mechanics - Level: core - Tags: waves, acoustics, audio, longitudinal, doppler - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/sound-waves ## What it is A loudspeaker cone pushes and pulls the air in front of it. Each air parcel only jiggles back and forth around its home position, but the pattern of **compressions** (crowded, high pressure) and **rarefactions** (spread out, low pressure) travels away at the speed of sound, about 343 m/s in room-temperature air. Because the jiggle is along the travel direction, sound is a **longitudinal** wave. The **frequency** `f` is how many compressions pass per second (pitch); the **wavelength** `λ = v/f` is the distance between them. A 440 Hz A4 is 0.78 m long in air; 100 Hz bass is 3.4 m, which is why it bends around walls. **Amplitude** sets the pressure swing; intensity grows with its square, and perceived loudness follows the logarithm of intensity (decibels). For game audio that physics shows up as distance attenuation (`1/r²` intensity, `−6 dB` per doubling of distance), propagation delay (thunder after lightning, far gunshots), Doppler shift for moving sources, and low-pass filtering for occlusion. Web Audio's `PannerNode` and Three.js's `PositionalAudio` implement the attenuation; delay and Doppler are often up to you. ## Key formulas - **Particle displacement:** $\xi(x,t) = A \sin(kx - \omega t),\quad k = \dfrac{2\pi}{\lambda},\ \omega = 2\pi f$ - **Pressure (90° ahead of displacement):** $p(x,t) = p_0 - B\,\dfrac{\partial \xi}{\partial x} = p_0 - B k A\cos(kx - \omega t)$ - **Wave speed:** $v = f\lambda$ - **Intensity and level:** $I \propto A^2,\qquad L = 10\log_{10}\dfrac{I}{I_0}\ \text{dB}$ - **Doppler (moving source):** $f' = f\,\dfrac{v}{v - v_s}$ — v_s is the source speed toward the listener. ## Rules of thumb - Speed of sound ≈ 331 + 0.6·T(°C) m/s in air; about 1480 m/s in water, so underwater audio should arrive almost instantly. - Human hearing: 20 Hz–20 kHz, wavelengths 17 m down to 1.7 cm. - Point source in open air: intensity ∝ 1/r², so the level drops 6 dB per doubling of distance. Games often use a gentler rolloff for playability. - Delay a far sound by `distance / 343` seconds; 100 m is a noticeable 0.29 s. ## Implementation Positional sound with physical delay and Doppler (Three.js + Web Audio): ```ts import * as THREE from "three" const SPEED = 343 const prev = new THREE.Vector3() const toListener = new THREE.Vector3() export function updateSound(sound: THREE.PositionalAudio, source: THREE.Object3D, listener: THREE.Object3D, dt: number) { sound.setRefDistance(2) // full volume within 2 m sound.setRolloffFactor(1) // inverse-distance falloff toListener.subVectors(listener.position, source.position) const dist = toListener.length() // Radial speed of the source toward the listener (m/s). const vs = prev.sub(source.position).multiplyScalar(-1 / dt).dot(toListener.normalize()) prev.copy(source.position) const rate = SPEED / (SPEED - THREE.MathUtils.clamp(vs, -150, 150)) sound.setPlaybackRate(rate) // Doppler pitch return dist / SPEED // seconds of delay to apply when (re)starting a one-shot } ``` ## Common mistakes - Drawing sound as a transverse wiggle: air moves along the direction of travel, not sideways. - Confusing amplitude with frequency: louder does not mean higher pitch. - Scaling volume linearly with distance; perceived loudness is logarithmic and intensity falls off as 1/r². - Applying Doppler from the frame-to-frame position delta without clamping, so a teleport produces a squeal. ## Interactive visual Rows of air parcels shuffle left and right as the grey speaker pushes them (200× slow motion). Orange parcels are compressed, cyan ones rarefied, and the yellow curve above is the pressure. Raise the frequency to pack compressions closer (shorter λ); raise the amplitude to make the parcels swing further. ## Prompt ```text Write a TypeScript Three.js helper for physically-flavoured positional audio. Inputs: a THREE.PositionalAudio, the source Object3D, the AudioListener's Object3D, dt and speed of sound (default 343 m/s). Each frame: compute distance, apply inverse-distance rolloff with refDistance 2 m, compute the source's radial velocity toward the listener and set playbackRate = c/(c − v_s) clamped to [0.5, 2], and expose delayFor(distance) for one-shots. Ignore velocity on frames where the source moved more than 10 m (teleport). Unit tests: a stationary source gives rate 1; approaching at 34.3 m/s gives rate ≈ 1.111; delay for 343 m is 1 s. ``` ## Related - [Spring-Mass Oscillator](https://gdcs.coody.app/physics/mechanics/spring-mass-oscillator.md) — Hooke's law, damping and a periodic push: the forced oscillator behind springy cameras, wobbly UI and suspension, and why driving at ω₀ explodes. - [EM Wave Propagation](https://gdcs.coody.app/physics/electromagnetism/em-wave-propagation.md) — Light is E and B regenerating each other. A 1D Yee grid (FDTD) leapfrogs them in time, slows them in glass and blows up past the Courant limit. - [Delta Time](https://gdcs.coody.app/game-feel/timing/delta-time.md) — Delta time is the seconds since the last frame. Multiply speeds and rates by it so a game plays the same at 30, 60 or 144 fps. ## Further reading - [OpenStax University Physics — Sound Waves](https://openstax.org/books/university-physics-volume-1/pages/17-1-sound-waves) - [MDN — Web audio spatialization basics](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Web_audio_spatialization_basics) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Spring-Mass Oscillator > Hooke's law, damping and a periodic push: the forced oscillator behind springy cameras, wobbly UI and suspension, and why driving at ω₀ explodes. - Category: Physics › Mechanics - Level: core - Tags: oscillation, hookes-law, damping, resonance, springs - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/mechanics/spring-mass-oscillator ## What it is A mass on a spring feels a restoring force `−k·x` that always points back to the rest position. Left alone it trades kinetic energy for elastic energy forever, oscillating at the **natural frequency** `ω₀ = √(k/m)`. A damper adds `−c·v`, which turns the swing into a decaying one. Push it with a periodic force `F₀ sin(ωt)` and, after the start-up transient dies out, the mass settles into motion at the **drive** frequency. Its amplitude depends on how close `ω` is to `ω₀`: far away the spring barely notices, near `ω₀` the response peaks, and with no damping it grows without bound. That peak is resonance. Games use this model constantly: spring-follow cameras, juicy UI that overshoots and settles, car suspension, ragdoll joints and [squash and stretch](https://gdcs.coody.app/game-feel/motion/squash-and-stretch). The damping ratio `ζ` is the one number designers actually tune: below 1 it bounces, at 1 it snaps in without overshoot, above 1 it crawls. ## Key formulas - **Equation of motion:** $m\ddot{x} + c\dot{x} + kx = F_0 \sin(\omega t)$ - **Natural frequency:** $\omega_0 = \sqrt{k/m}$ - **Damping ratio:** $\zeta = \dfrac{c}{2\sqrt{km}}$ — ζ < 1 bounces, ζ = 1 is critical (fastest settle, no overshoot), ζ > 1 is sluggish. - **Steady-state amplitude:** $A = \dfrac{F_0}{\sqrt{(k - m\omega^2)^2 + (c\omega)^2}}$ - **Stored elastic energy:** $U = \tfrac{1}{2} k x^2$ ## Rules of thumb - Tune springs by frequency and damping ratio, not by raw k and c: pick `ω₀` (2–8 Hz feels snappy for UI) and `ζ` (0.4–0.7 for a playful bounce, 1.0 for a camera), then derive `k = mω₀²` and `c = 2ζ√(km)`. - Use semi-implicit (symplectic) Euler: update velocity first, then position with the new velocity. Explicit Euler gains energy and blows up. - Step at a fixed rate (120 Hz is plenty) and keep `ω₀·dt` well under 1; stiff springs need smaller steps or an analytic/implicit solver. - Resonance is sharp when damping is small: at ζ = 0.05 the peak is about 10× the static deflection `F₀/k`. ## Implementation A critically-tunable spring for any value (camera, UI, bone): ```ts export class Spring { x = 0 v = 0 constructor(public freqHz = 4, public zeta = 0.5) {} /** Advance toward target with a fixed step; call from a fixed-timestep loop. */ step(target: number, dt: number) { const w = 2 * Math.PI * this.freqHz const k = w * w // m = 1 const c = 2 * this.zeta * w const a = -k * (this.x - target) - c * this.v this.v += a * dt // semi-implicit: velocity first this.x += this.v * dt return this.x } } // const camY = new Spring(3, 1) // critically damped follow // camera.position.y = camY.step(player.position.y, 1 / 120) ``` ## Common mistakes - Writing position before velocity (explicit Euler). The oscillation slowly gains energy and the spring eventually explodes. - Scaling stiffness with frame time instead of using a fixed step, so the bounce looks different at 30 and 144 FPS. - Tuning k and c by hand: doubling the mass silently changes both the frequency and the damping ratio. - Driving an undamped spring at its own frequency (e.g. a periodic footstep shake) and watching the amplitude grow without limit. ## Interactive visual The orange block rides a cyan spring around the dashed rest line, and the orange trace above records its displacement over time. The default drive sits near resonance (ω ≈ ω₀); move the drive frequency away, or raise damping, and the steady amplitude in the readout drops. ## Prompt ```text Write a TypeScript Spring class for a Three.js game that smooths a number (or THREE.Vector3) toward a target. Constructor takes frequency in Hz and damping ratio ζ; derive k = (2πf)² and c = 2ζ(2πf) with unit mass. Provide step(target, dt) using semi-implicit Euler and a reset(value). It must be called from a fixed 120 Hz loop. Edge cases: ζ = 0 (pure oscillation), ζ ≥ 1 (no overshoot), dt larger than 1/30 should be subdivided. Add unit tests: with ζ = 1 a step response never overshoots the target, and with ζ = 0 total energy ½kx² + ½v² stays within 1% over 10 s. ``` ## Related - [Damped Pendulum](https://gdcs.coody.app/physics/mechanics/damped-pendulum.md) — A swinging weight pulled by sin θ: its period is only 2π√(L/g) for small swings, and damping decays the arc. Grapples, lamps and ropes in games. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Squash and Stretch](https://gdcs.coody.app/game-feel/motion/squash-and-stretch.md) — Stretch an object along its velocity and squash it on impact, scaling the other axes so volume stays constant. Motion reads as fast and elastic. - [Sound Waves](https://gdcs.coody.app/physics/mechanics/sound-waves.md) — Sound is a longitudinal pressure wave: frequency sets pitch and wavelength, amplitude sets loudness. The physics behind 3D audio, delay and Doppler. ## Further reading - [OpenStax University Physics — Forced Oscillations](https://openstax.org/books/university-physics-volume-1/pages/15-6-forced-oscillations) - [Gaffer On Games — Integration Basics](https://gafferongames.com/post/integration_basics/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Charged Particle Motion > The Lorentz force curls a charge around magnetic field lines and crossed E and B make it drift sideways. Integrate it with Boris, not Euler. - Category: Physics › Electromagnetism - Level: advanced - Tags: lorentz-force, magnetism, integration, boris, rk4 - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/electromagnetism/charged-particle-motion ## What it is A moving charge in a magnetic field feels a force `q·v × B` that is always perpendicular to its velocity. It changes the direction of motion but never the speed, so in a uniform field the particle circles at the **Larmor radius** `r = mv⊥/(|q|B)` with a period that does not depend on speed. Add an electric field perpendicular to B and the circle becomes a **cycloid** that drifts sideways at `E/B` in the direction of `E × B`, the same for positive and negative charges. That drift is why plasmas move across magnetic fields and why the particle in the visual walks to the right instead of falling along E. Numerically this is a great integrator test. Explicit Euler spirals outward because each step adds a little speed; RK4 is very accurate but slowly loses energy; the **Boris push** splits the electric kick in two and applies the magnetic part as an exact rotation, so speed is conserved to round-off at any step size. Run it at a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep) so the orbit does not depend on frame rate. ## Key formulas - **Lorentz force:** $m\frac{d\mathbf{v}}{dt} = q\left(\mathbf{E} + \mathbf{v} \times \mathbf{B}\right)$ - **Larmor radius and cyclotron frequency:** $r_L = \frac{m v_\perp}{|q| B}, \qquad \omega_c = \frac{|q| B}{m}$ - **E × B drift:** $\mathbf{v}_d = \frac{\mathbf{E} \times \mathbf{B}}{B^2}$ — Independent of charge sign and mass. - **Boris rotation:** $\mathbf{t} = \frac{q\mathbf{B}\,\Delta t}{2m},\; \mathbf{v}' = \mathbf{v}^- + \mathbf{v}^- \times \mathbf{t},\; \mathbf{v}^+ = \mathbf{v}^- + \frac{2\,\mathbf{v}' \times \mathbf{t}}{1 + t^2}$ — v⁻ and the final velocity each get half an electric kick qEΔt/(2m). ## Rules of thumb - Keep `ω_c·Δt` below ~0.1 for a smooth trail (≥ 60 steps per gyration); Boris stays stable up to ω_c·Δt ≈ 2 but the phase drifts. - With q/m = 1 and B = 1.4 the period is 2π/1.4 ≈ 4.5 s; speed the sim up 2× for readable motion. - Start the particle at the guiding centre offset `(v − E/B)/B` so the cycloid stays centred on screen. - Pick E perpendicular to B to see pure drift; any E component along B accelerates the particle without limit. - Need energy conservation over minutes of play (orbits, magnets, tractor beams)? Use Boris or another symplectic method, not RK4. ## Implementation Allocation-free Boris push (call at a fixed rate): ```ts type P = { x: number; y: number; z: number; vx: number; vy: number; vz: number } type V3 = readonly [number, number, number] export function boris(p: P, E: V3, B: V3, qm: number, dt: number) { const h = 0.5 * qm * dt let vx = p.vx + h * E[0], vy = p.vy + h * E[1], vz = p.vz + h * E[2] const tx = h * B[0], ty = h * B[1], tz = h * B[2] const f = 2 / (1 + tx * tx + ty * ty + tz * tz) const px = vx + (vy * tz - vz * ty) const py = vy + (vz * tx - vx * tz) const pz = vz + (vx * ty - vy * tx) vx += f * (py * tz - pz * ty) vy += f * (pz * tx - px * tz) vz += f * (px * ty - py * tx) p.vx = vx + h * E[0]; p.vy = vy + h * E[1]; p.vz = vz + h * E[2] p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt } ``` ## Common mistakes - Integrating v × B with explicit Euler: the orbit spirals outward and the particle gains energy from nothing. - Assuming the particle drifts along E. In crossed fields it drifts along E × B, perpendicular to both. - Getting the cross-product order wrong (B × v), which reverses the direction of rotation. - Stepping with the render delta: the gyration radius then depends on frame rate. - Allocating a new Vector3 per step for v × B inside the render loop. ## Interactive visual The orange charge leaves a cyan trail and the yellow stub is its velocity; faint yellow arrows show E (up) and grey dots show B coming out of the screen. Change E, B and speed, and switch the integrator to explicit Euler to watch the orbit spiral outward. ## Prompt ```text Implement charged-particle motion in TypeScript for a Three.js scene. Write three allocation-free steppers that mutate {x,y,z,vx,vy,vz}: explicit Euler, classic RK4 (module-level scratch Float64Array) and the Boris push, each taking uniform E and B tuples, q/m and dt. Drive them from a 120 Hz fixed-step accumulator, keep a 240-point trail in a Float32Array ring written into an existing line geometry, and restart the particle when it leaves a ±5 × ±3.4 box. Expose larmorRadius(v, B, qm) and exbDrift(E, B). Tests: Boris keeps speed within 1e-12 over 20,000 steps in pure B; RK4 orbit radius matches v/(qm·B) to 1e-3; Euler speed grows; the mean x-velocity over 20 whole periods with E = 0.3ŷ, B = 1.5ẑ equals 0.2. ``` ## Related - [Electric Field Lines](https://gdcs.coody.app/physics/electromagnetism/electric-field-lines.md) — Coulomb fields add as vectors; tracing streamlines along the net field draws lines that leave positive charges, end on negative ones and never cross. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Mercury's Perihelion Precession](https://gdcs.coody.app/physics/relativity/mercury-precession.md) — General relativity adds a small 1/r⁴ pull that makes an orbit's closest point creep forward each lap: about 43 arcseconds per century for Mercury. ## Further reading - [OpenStax University Physics Vol. 2 — Motion of a Charged Particle in a Magnetic Field](https://openstax.org/books/university-physics-volume-2/pages/11-3-motion-of-a-charged-particle-in-a-magnetic-field) - [Gaffer On Games — Integration Basics](https://gafferongames.com/post/integration_basics/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Electric Field Lines > Coulomb fields add as vectors; tracing streamlines along the net field draws lines that leave positive charges, end on negative ones and never cross. - Category: Physics › Electromagnetism - Level: common - Tags: electrostatics, coulomb, vector-field, streamlines, superposition - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/electromagnetism/electric-field-lines ## What it is Every point charge creates a field that points straight away from it (or toward it, if negative) and weakens with the **square** of distance. With several charges you simply add the vectors: that is superposition, and it is the whole trick. A **field line** is a curve that is tangent to the net field everywhere. You draw one by starting just outside a positive charge and repeatedly taking a small step along the normalised field direction until the line reaches a negative charge or leaves the screen. Start a number of lines proportional to each charge and the line density shows field strength. Game uses: magnet and force-field abilities, homing projectiles that follow a potential, flow fields for crowds and particles (the same tracing as a [particle system](https://gdcs.coody.app/systems/patterns/particle-system) advecting through a vector field), and debug views of any vector field. The same field drives the [charged particle motion](https://gdcs.coody.app/physics/electromagnetism/charged-particle-motion) sheet. ## Key formulas - **Field of point charges (superposition):** $\mathbf{E}(\mathbf{r}) = k \sum_i q_i \frac{\mathbf{r} - \mathbf{r}_i}{\lVert \mathbf{r} - \mathbf{r}_i \rVert^{3}}$ — k = 1/(4πε₀) ≈ 8.99 × 10⁹ N·m²/C². Scenes usually set k = 1. - **Force on a test charge:** $\mathbf{F} = q\,\mathbf{E}$ - **Streamline step:** $\mathbf{p}_{n+1} = \mathbf{p}_n \pm h\,\frac{\mathbf{E}(\mathbf{p}_n)}{\lVert \mathbf{E}(\mathbf{p}_n) \rVert}$ — + from positive seeds, − when tracing backwards from negative seeds. - **Softened distance:** $r^2 \rightarrow \max(r^2, \varepsilon)$ — Caps the field near a charge so nothing divides by zero. ## Rules of thumb - Seed `≈ 12 × |q|` lines per charge, evenly spaced on a circle of radius ~0.3–0.4 units around it. - Step size around 0.1 units and 80–120 steps per line; stop within ~0.4 units of another charge or 6 units from the origin. - Like charges produce a **null point** between them where E = 0; stop tracing when |E| falls below ~1e-4. - Normalise the step: stepping by raw E makes lines jump past charges where the field is huge. - Trace once when charges change and cache the polylines; only animate markers along them per frame. ## Implementation Field from point charges and one traced line (k = 1): ```ts type Charge = { x: number; y: number; q: number } export function fieldAt(cs: Charge[], x: number, y: number) { let ex = 0, ey = 0 for (const c of cs) { const dx = x - c.x, dy = y - c.y const r2 = Math.max(dx * dx + dy * dy, 0.12) // softening const s = c.q / (r2 * Math.sqrt(r2)) ex += dx * s; ey += dy * s } return { ex, ey } } export function trace(cs: Charge[], seed: Charge, angle: number, h = 0.1) { const dir = seed.q > 0 ? 1 : -1 let x = seed.x + 0.35 * Math.cos(angle), y = seed.y + 0.35 * Math.sin(angle) const pts = [x, y, 0] for (let i = 0; i < 100; i++) { const { ex, ey } = fieldAt(cs, x, y) const len = Math.hypot(ex, ey) if (len < 1e-4) break // null point x += (dir * h * ex) / len; y += (dir * h * ey) / len pts.push(x, y, 0) if (cs.some((c) => c !== seed && Math.hypot(x - c.x, y - c.y) < 0.4)) break } return pts // feed to THREE.BufferGeometry / drei } ``` ## Common mistakes - Not normalising the step, so lines overshoot charges and wander off where the field is strong. - Seeding a fixed number of lines per charge, which hides the fact that a 2q charge has twice the flux. - Seeding from both charges of a dipole, which draws every line twice (once each way). - Dividing by r² without softening: a seed that lands on a charge produces NaN and an invisible line. - Re-tracing every line every frame when nothing moved; trace on change and cache. ## Interactive visual Cyan spheres are positive charges, rose spheres negative and grey neutral; yellow lines are traced field lines with orange markers flowing along E. Slide the right charge through zero to flip a dipole into two like charges, and change spacing and line density. ## Prompt ```text Write a TypeScript module for Three.js that draws 2D electric field lines for a list of point charges {x, y, q} (k = 1). Implement fieldAt(charges, x, y, out) with a softened r² (min 0.12) that writes into a reusable object, and traceLines(charges, linesPerUnitCharge = 12) that seeds round(12·|q|) lines on a 0.36-unit circle around each positive charge (or every charge if none is positive), steps 0.11 units along the normalised field (backwards for negative seeds), and stops near another charge (< 0.42), outside radius 6, after 110 steps or when |E| < 1e-4. Return flat Float32Arrays for THREE.Line. Add tests: the field at a dipole's midpoint points from + to −, falls as 1/r² from a single charge, and a line from + ends on −. ``` ## Related - [Charged Particle Motion](https://gdcs.coody.app/physics/electromagnetism/charged-particle-motion.md) — The Lorentz force curls a charge around magnetic field lines and crossed E and B make it drift sideways. Integrate it with Boris, not Euler. - [EM Wave Propagation](https://gdcs.coody.app/physics/electromagnetism/em-wave-propagation.md) — Light is E and B regenerating each other. A 1D Yee grid (FDTD) leapfrogs them in time, slows them in glass and blows up past the Courant limit. - [Particle System](https://gdcs.coody.app/systems/patterns/particle-system.md) — An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. ## Further reading - [OpenStax University Physics Vol. 2 — Electric Field Lines](https://openstax.org/books/university-physics-volume-2/pages/5-6-electric-field-lines) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # EM Wave Propagation > Light is E and B regenerating each other. A 1D Yee grid (FDTD) leapfrogs them in time, slows them in glass and blows up past the Courant limit. - Category: Physics › Electromagnetism - Level: advanced - Tags: maxwell, fdtd, yee-grid, courant, waves - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/electromagnetism/em-wave-propagation ## What it is An electromagnetic wave is a changing electric field that creates a changing magnetic field, which creates the electric field again. In a plane wave E and B are perpendicular to each other and to the direction of travel, in phase, and move at `c = 1/√(με)`. The **finite-difference time-domain** (FDTD) method solves Maxwell's curl equations directly. Kane Yee's trick is to store E and H on grids offset by half a cell in space and half a step in time, so every derivative is a centred difference. Each step updates H from the spatial change of E, then E from the spatial change of H: a leapfrog. The scheme is explicit, so it has a stability limit. The **Courant number** `S = cΔt/Δx` must not exceed 1 in 1D (1/√2 in 2D, 1/√3 in 3D); above it, errors grow every step until the grid fills with garbage. Inside a dielectric with permittivity εr the wave slows by `√εr`, its wavelength shrinks, and part of it reflects at the boundary. The same leapfrog pattern runs water ripples, audio propagation and [sound waves](https://gdcs.coody.app/physics/mechanics/sound-waves) in games; it is the grown-up version of the [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep) rule that stability depends on step size. ## Key formulas - **Maxwell's curl equations (1D, Ey and Hz):** $\varepsilon\frac{\partial E_y}{\partial t} = -\frac{\partial H_z}{\partial x}, \qquad \mu\frac{\partial H_z}{\partial t} = -\frac{\partial E_y}{\partial x}$ - **Yee update (normalised: c = 1, Δx = 1):** $H_{i+\frac12}^{n+\frac12} = H_{i+\frac12}^{n-\frac12} - S\,(E_{i+1}^n - E_i^n), \quad E_i^{n+1} = E_i^n - \frac{S}{\varepsilon_r}\,(H_{i+\frac12}^{n+\frac12} - H_{i-\frac12}^{n+\frac12})$ - **Courant stability:** $S = \frac{c\,\Delta t}{\Delta x} \le \frac{1}{\sqrt{d}}$ — d = number of spatial dimensions. - **Speed and wavelength in a dielectric:** $v = \frac{c}{\sqrt{\varepsilon_r}}, \qquad \lambda = \frac{\lambda_0}{\sqrt{\varepsilon_r}}$ - **Mur absorbing boundary (first order):** $E_0^{n+1} = E_1^{n} + \frac{S-1}{S+1}\left(E_1^{n+1} - E_0^{n}\right)$ ## Rules of thumb - Resolve the shortest wavelength (inside the densest material) with at least 10–20 cells, or numerical dispersion slows short waves. - Use S ≈ 0.9 of the limit; in 1D, S = 1 is the 'magic time step' where a pulse moves exactly one cell per step with zero dispersion. - Ramp a sinusoidal source over 1–2 periods; switching it on instantly launches a broadband spike. - A soft source (add to E) lets reflected waves pass through it; a hard source (overwrite E) reflects them. - Without an absorbing boundary the grid ends are perfect mirrors and you get standing waves. ## Implementation One 1D Yee step with a soft source and Mur boundaries: ```ts export function step(ey: Float64Array, hz: Float64Array, eps: Float64Array, S: number, src: number, value: number, mur: Float64Array) { const n = ey.length mur[0] = ey[0]; mur[1] = ey[1]; mur[2] = ey[n - 1]; mur[3] = ey[n - 2] for (let i = 0; i < n - 1; i++) hz[i] -= S * (ey[i + 1] - ey[i]) for (let i = 1; i < n - 1; i++) ey[i] -= (S / eps[i]) * (hz[i] - hz[i - 1]) ey[src] += value // soft source const m = (S - 1) / (S + 1) ey[0] = mur[1] + m * (ey[1] - mur[0]) ey[n - 1] = mur[3] + m * (ey[n - 2] - mur[2]) } ``` ## Common mistakes - Storing E and H at the same grid points and times, which loses the second-order accuracy and the stability of the staggered scheme. - Picking Δt from the frame rate instead of from the Courant limit; a slow frame then makes S > 1 and the sim explodes. - Checking stability with the slow speed inside a dielectric. The limit is set by the fastest wave on the grid (vacuum). - Forgetting an absorbing boundary and mistaking the resulting standing wave for physics. ## Interactive visual Cyan is the electric field Ey (vertical), violet the magnetic field Hz (horizontal), launched from the orange source and absorbed at both ends; the translucent block is a dielectric slab. Raise εr to slow and shorten the wave inside the slab, and push the Courant number past 1 to watch the explicit scheme blow up. ## Prompt ```text Write a 1D FDTD (Yee) electromagnetic solver in TypeScript for a Three.js visual. Use normalised units (c = 1, Δx = 1) with Float64Array Ey on n nodes, Hz on n−1 half nodes and a per-node relative permittivity array. stepFdtd(grid, S, sourceIndex, source(t)) updates H then E, adds a soft source, applies first-order Mur boundaries at both ends and allocates nothing. Render Ey along y and Hz along z by writing into existing line geometries each frame, clamping non-finite values. Tests: at S = 1 a matched Gaussian pulse moves exactly one cell per step; at S = 0.5 the energy after the pulse exits is < 2% of the start (Mur absorbs); at S = 1.1 energy exceeds 1e6 within 400 steps. ``` ## Related - [Electric Field Lines](https://gdcs.coody.app/physics/electromagnetism/electric-field-lines.md) — Coulomb fields add as vectors; tracing streamlines along the net field draws lines that leave positive charges, end on negative ones and never cross. - [Sound Waves](https://gdcs.coody.app/physics/mechanics/sound-waves.md) — Sound is a longitudinal pressure wave: frequency sets pitch and wavelength, amplitude sets loudness. The physics behind 3D audio, delay and Doppler. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [OpenStax University Physics Vol. 2 — Plane Electromagnetic Waves](https://openstax.org/books/university-physics-volume-2/pages/16-2-plane-electromagnetic-waves) - [John B. Schneider — Understanding the FDTD Method (free textbook)](https://eecs.wsu.edu/~schneidj/ufdtd/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Boltzmann Energy Distribution > At temperature T a state of energy E is weighted by e^(−E/kT). Metropolis sampling reproduces the distribution with a simple accept/reject rule. - Category: Physics › Thermodynamics - Level: advanced - Tags: statistical-mechanics, boltzmann, metropolis, monte-carlo, sampling - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/thermodynamics/boltzmann-distribution ## What it is In thermal equilibrium the probability of finding a system in a particular state falls off exponentially with that state's energy: `e^(−E/kT)`. Temperature sets the scale: at low T almost everything sits near the ground state, at high T the tail reaches far. What you measure is usually the probability of an **energy**, not a single state, so the Boltzmann factor is multiplied by the **density of states** `g(E)`, the number of states with that energy. For a 3D ideal gas `g ∝ √E`, which turns the plain exponential into a hump that peaks at `kT/2` and has mean `(3/2)kT`. In general, with `g ∝ E^p`, the mean is `(p + 1)kT`. The **Metropolis** algorithm samples this without ever computing the normalisation: propose a small random change, always accept it if the weighted probability goes up, otherwise accept it with probability equal to the ratio. Run many walkers and their histogram converges to the target distribution. In games the same accept-uphill-sometimes rule is simulated annealing for level generation and layout, and Boltzmann (softmax) weights pick AI actions with a tunable 'temperature' of randomness. The physical picture connects to the [ideal gas](https://gdcs.coody.app/physics/thermodynamics/ideal-gas), whose particle energies follow exactly the p = ½ curve. ## Key formulas - **Energy distribution:** $P(E) = \frac{g(E)\, e^{-E/k_B T}}{Z}, \qquad Z = \int_0^\infty g(E)\, e^{-E/k_B T}\, dE$ - **Power-law density of states:** $g(E) \propto E^{p} \;\Rightarrow\; P(E) = \frac{E^{p}\, e^{-E/k_B T}}{\Gamma(p+1)\,(k_B T)^{p+1}}$ — p = ½ for a 3D gas, 0 in 2D, −½ in 1D. - **Mean and most likely energy:** $\langle E \rangle = (p+1)\,k_B T, \qquad E_{\text{peak}} = p\,k_B T$ - **Metropolis acceptance:** $A(E \to E') = \min\!\left(1,\; \frac{g(E')}{g(E)}\, e^{-(E' - E)/k_B T}\right)$ - **Gibbs entropy:** $S = -k_B \sum_i p_i \ln p_i$ ## Rules of thumb - Use a symmetric proposal, e.g. `E' = |E + δ|` with δ uniform in ±kT; asymmetric proposals need the Hastings correction. - Aim for 30–60% acceptance: scale the step with kT so it stays in that range at every temperature. - Discard the first few hundred sweeps (burn-in) before measuring averages. - 1,000–4,000 walkers gives a smooth 40–60 bin histogram; average it over a few sweeps to hide noise. - Softmax action choice is the same formula: P(a) ∝ e^(score/T). T → 0 is greedy, large T is uniform random. ## Implementation One Metropolis sweep over many walkers (no allocation): ```ts export function sweep(E: Float32Array, kT: number, p: number, rand: () => number) { let accepted = 0 for (let i = 0; i < E.length; i++) { const e = E[i] const next = Math.abs(e + (rand() * 2 - 1) * kT) // symmetric proposal const ratio = e === 0 ? 1 : (next / e) ** p * Math.exp(-(next - e) / kT) // g(E) = E^p if (rand() < Math.min(1, ratio)) { E[i] = next accepted++ } } return accepted / E.length // keep near 0.3–0.6 } ``` ## Common mistakes - Forgetting the density of states and expecting a pure exponential: a 3D gas has very few particles near zero energy. - Rejecting a move and then not counting the old state again; a rejection still produces a sample. - Using a proposal that cannot reach negative-side reflections symmetrically (e.g. clamping to 0), which biases low energies. - Measuring averages before burn-in, so the result still remembers the starting energy. ## Interactive visual Cyan bars are the histogram of 1,500 Metropolis walkers (orange dots below the axis), the white curve is the exact distribution, and the dashed yellow line is the theoretical mean with a yellow dot tracking the walkers' mean. Every walker starts at the same low energy; raise T to broaden the curve or change p to switch between 1D, 2D and 3D-like systems. ## Prompt ```text Write a Metropolis sampler for the Boltzmann energy distribution in TypeScript. Inputs: a Float32Array of walker energies, temperature kT, density-of-states exponent p (g ∝ E^p) and a seeded PRNG. Each sweep proposes E' = |E + δ| with δ uniform in ±max(0.3, kT) and accepts with min(1, (E'/E)^p·e^(−(E'−E)/kT)), handling E = 0 and p = 0 safely; return the acceptance ratio. Also implement the normalised density E^p·e^(−E/kT)/(Γ(p+1)·kT^(p+1)) with a Lanczos gamma. Render a 48-bin exponentially averaged histogram as an InstancedMesh with the theory curve overlaid in Three.js. Tests: density integrates to 1; acceptance matches the reference 3D rule for p = ½; mean energy after burn-in is (p+1)kT within 5% for p = 0, ½ and 1.5. ``` ## Related - [Ideal Gas Kinetics](https://gdcs.coody.app/physics/thermodynamics/ideal-gas.md) — Temperature is average kinetic energy and pressure is momentum delivered to the walls. Bounce point particles in a box and PV = NkT falls out. - [Heat Diffusion](https://gdcs.coody.app/physics/thermodynamics/heat-diffusion.md) — Heat flows down temperature gradients at a rate set by the Laplacian. The explicit grid update is four lines of code, and is stable only if αΔt/Δx² ≤ ¼. - [Finite State Machine](https://gdcs.coody.app/ai/decision/finite-state-machine.md) — An FSM keeps an AI or character in exactly one state, such as Idle, Patrol, Chase or Attack. A table of events decides every legal transition. ## Further reading - [OpenStax University Physics Vol. 2 — Distribution of Molecular Speeds](https://openstax.org/books/university-physics-volume-2/pages/2-4-distribution-of-molecular-speeds) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Heat Diffusion > Heat flows down temperature gradients at a rate set by the Laplacian. The explicit grid update is four lines of code, and is stable only if αΔt/Δx² ≤ ¼. - Category: Physics › Thermodynamics - Level: common - Tags: diffusion, heat-equation, finite-difference, stability, grid - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/thermodynamics/heat-diffusion ## What it is The heat equation says a point warms up when it is colder than the average of its surroundings and cools when it is hotter. The second derivative (the **Laplacian**) measures exactly that difference, and the diffusivity `α` sets how fast it evens out. On a grid, the Laplacian becomes 'sum of the four neighbours minus four times the centre'. The **explicit** (forward-time, centred-space) update adds a fraction `r = αΔt/Δx²` of that difference each step. It is cheap and trivially parallel, which is why it shows up in shaders and cellular simulations. The catch is stability. In 2D the update is only stable for `r ≤ ¼` (½ in 1D, ⅙ in 3D). Above that, the checkerboard pattern of alternating hot and cold cells grows every step and temperatures go negative. Halving the cell size quarters the allowed time step, so fine grids need many substeps or an implicit solver. In games the same diffusion step spreads heat, gas, scent maps for AI, influence maps, fog of war softening, wet surfaces and blur. Run it at a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep) and, for big grids, [time-slice](https://gdcs.coody.app/performance/cpu/time-slicing) it across frames. ## Key formulas - **Heat equation:** $\frac{\partial T}{\partial t} = \alpha \nabla^2 T = \alpha\left(\frac{\partial^2 T}{\partial x^2} + \frac{\partial^2 T}{\partial y^2}\right)$ - **Explicit (FTCS) update:** $T_{i,j}^{n+1} = T_{i,j}^n + r\left(T_{i+1,j}^n + T_{i-1,j}^n + T_{i,j+1}^n + T_{i,j-1}^n - 4T_{i,j}^n\right)$ - **Stability (mesh Fourier number):** $r = \frac{\alpha\,\Delta t}{\Delta x^2} \le \frac{1}{2d}$ — ¼ in 2D. Larger r makes the scheme oscillate and diverge. - **Diffusion length:** $L \approx \sqrt{4\alpha t}$ — Spreading time grows with the square of distance. ## Rules of thumb - Choose Δt = 0.8–0.9 × Δx²/(4α) and run as many substeps per frame as the simulated time needs. - Always read from one buffer and write to another (ping-pong); updating in place mixes old and new values. - Cold edges = Dirichlet (set boundary cells to 0); insulated edges = Neumann (copy the neighbour). - A heater is just cells you reset to a fixed temperature after every step. - Approximate diffusivities: copper 1.1×10⁻⁴ m²/s, steel 1.2×10⁻⁵, water 1.4×10⁻⁷. For games, pick α by how many seconds heat should take to cross the map. ## Implementation Ping-pong explicit diffusion with a stable step count: ```ts export function diffuse(a: Float32Array, b: Float32Array, n: number, alpha: number, dx: number, seconds: number) { const dtMax = (dx * dx) / (4 * alpha) const steps = Math.ceil(seconds / (0.9 * dtMax)) const r = (alpha * (seconds / steps)) / (dx * dx) // always <= 0.225 for (let s = 0; s < steps; s++) { for (let j = 1; j < n - 1; j++) { for (let i = 1; i < n - 1; i++) { const k = j * n + i b[k] = a[k] + r * (a[k - 1] + a[k + 1] + a[k - n] + a[k + n] - 4 * a[k]) } } ;[a, b] = [b, a] // cold edges stay 0 in both buffers } return a // latest field } ``` ## Common mistakes - Using the frame delta as Δt: a hitch makes r > ¼ and the grid explodes into a red-and-blue checkerboard. - Updating the grid in place, which makes heat flow faster in the scan direction. - Refining the grid without shrinking Δt; the limit scales with Δx². - Forgetting to clamp or reset the heater cells after each step, so the source slowly cools. ## Interactive visual Height and colour show temperature (dark cold, through cyan and green, to orange hot) on a plate with cold edges and an orange heater in the middle; red cells mean negative, impossible temperatures. Change the diffusivity and heater temperature, and push Δt/Δt_max past 1 to break the stability limit. ## Prompt ```text Implement a 2D explicit finite-difference heat solver in TypeScript for a Three.js heightfield. Inputs: grid size n, cell size dx, diffusivity alpha, a Float32Array of fixed temperatures (−1 for free cells) and the seconds to advance. Use two preallocated Float32Array buffers (ping-pong), cold Dirichlet edges, and choose Δt = 0.8·dx²/(4α) with as many substeps as needed. Write heights and vertex colours into an existing PlaneGeometry each frame without allocating, and recompute normals. Tests: maxStableDt(0.2, 0.1) = 0.0125; with r = 0.25 a single hot cell never exceeds its start value or goes negative over 300 steps; with r = 0.3 it oscillates to |T| > 10 and negative values within 80 steps. ``` ## Related - [Ideal Gas Kinetics](https://gdcs.coody.app/physics/thermodynamics/ideal-gas.md) — Temperature is average kinetic energy and pressure is momentum delivered to the walls. Bounce point particles in a box and PV = NkT falls out. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. - [Time Slicing](https://gdcs.coody.app/performance/cpu/time-slicing.md) — Split expensive per-entity work into buckets and run one bucket per frame. Cost per frame drops by the bucket count; each entity reacts a little later. ## Further reading - [OpenStax University Physics Vol. 2 — Mechanisms of Heat Transfer](https://openstax.org/books/university-physics-volume-2/pages/1-6-mechanisms-of-heat-transfer) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Ideal Gas Kinetics > Temperature is average kinetic energy and pressure is momentum delivered to the walls. Bounce point particles in a box and PV = NkT falls out. - Category: Physics › Thermodynamics - Level: core - Tags: kinetic-theory, pressure, temperature, particles, maxwell-boltzmann - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/thermodynamics/ideal-gas ## What it is An ideal gas is a crowd of tiny particles that never interact except by bouncing elastically off the container. Every bounce reverses one velocity component and hands the wall a momentum of `2m|v⊥|`. Add up those impulses per second per square metre and you get **pressure**. **Temperature** is the average kinetic energy per particle: `½m⟨v²⟩ = (3/2)kT`. Double T and the root-mean-square speed grows by √2, particles hit walls more often and harder, and pressure doubles. Halve the volume and the same particles hit each wall twice as often. That is the whole of `PV = NkT`, recovered from mechanics. Velocities in equilibrium follow the Maxwell–Boltzmann distribution: each component is a Gaussian with variance `kT/m`. Seeding a sim with Gaussian velocities from a proper PRNG gives the right spread of speeds from the first frame. For games this is the template for every cheap [particle system](https://gdcs.coody.app/systems/patterns/particle-system) with box collisions: integrate positions at a [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep), reflect on walls, and measure aggregate quantities instead of tracking every interaction. Real gases add collisions between particles, which needs [spatial partitioning](https://gdcs.coody.app/performance/world/spatial-partitioning). ## Key formulas - **Ideal gas law:** $PV = N k_B T$ - **Temperature from kinetic energy:** $\tfrac{1}{2} m \langle v^2 \rangle = \tfrac{3}{2} k_B T$ - **RMS speed:** $v_{\text{rms}} = \sqrt{\frac{3 k_B T}{m}}$ - **Pressure from wall impulses:** $P = \frac{1}{A\,\Delta t}\sum_{\text{hits}} 2 m |v_\perp|$ - **Maxwell–Boltzmann velocity component:** $v_x \sim \mathcal{N}\!\left(0, \frac{k_B T}{m}\right)$ ## Rules of thumb - Room-temperature nitrogen: v_rms ≈ 517 m/s. In sim units set m = k_B = 1 so v_rms = √(3T). - Store unit-temperature velocities and scale by √T when stepping; changing the temperature slider then rescales speeds instantly and correctly. - Reflect position about the wall (`p = 2b − p`) and flip the velocity; just clamping loses distance and biases pressure. - Average pressure over ~1–2 s of hits; with 100–300 particles single-frame readings are noisy. - 300 instanced spheres at 120 Hz is trivial; per-particle collisions are where costs explode (O(N²) without a grid). ## Implementation Box step that also measures pressure (m = k = 1): ```ts export function stepGas(pos: Float32Array, vel: Float32Array, n: number, dt: number, T: number, half: [number, number, number]) { const s = Math.sqrt(T) // vel holds unit-temperature velocities let impulse = 0 for (let i = 0; i < n * 3; i++) { const b = half[i % 3] const v = vel[i] * s let p = pos[i] + v * dt if (p > b || p < -b) { p = 2 * (p > 0 ? b : -b) - p // specular reflection vel[i] = -vel[i] impulse += 2 * Math.abs(v) } pos[i] = p } const area = 8 * (half[0] * half[1] + half[1] * half[2] + half[0] * half[2]) return impulse / (area * dt) // instantaneous pressure; average it } ``` ## Common mistakes - Seeding random velocities with a weak or correlated generator (e.g. an LCG seeded with the particle index), which gives nearly identical speeds instead of a Maxwellian spread. - Using uniform random velocities: the mean energy can be right but the speed distribution is wrong. - Clamping particles to the wall without reflecting, which slowly drains kinetic energy. - Comparing measured pressure with NkT/V using the slider temperature instead of the gas's actual kinetic temperature for a small N. ## Interactive visual Particles are coloured by speed from cyan (slow) to orange (fast) inside a translucent box; the right wall is a piston that glows yellow with the measured pressure. Raise temperature or particle count, or narrow the box, and watch the glow and the P = NkT/V readout rise together. ## Prompt ```text Build an ideal-gas box in TypeScript with Three.js. Create N ≤ 300 particles with uniform positions and Gaussian unit-temperature velocities from a seeded mulberry32 + Box–Muller generator. Each 1/120 s fixed step, advance positions by v·√T·dt, reflect specularly off the walls of a box whose width is adjustable, and return the summed wall impulse 2|v⊥|. Render with one InstancedMesh, colouring each instance from slow (cyan) to fast (orange), with no per-frame allocation. Tests: over 40,000 steps the measured pressure impulse/(area·time) matches N·T_kin/V within 3%, and 2,000 seeded particles have kinetic temperature ≈ 1. ``` ## Related - [Boltzmann Energy Distribution](https://gdcs.coody.app/physics/thermodynamics/boltzmann-distribution.md) — At temperature T a state of energy E is weighted by e^(−E/kT). Metropolis sampling reproduces the distribution with a simple accept/reject rule. - [Heat Diffusion](https://gdcs.coody.app/physics/thermodynamics/heat-diffusion.md) — Heat flows down temperature gradients at a rate set by the Laplacian. The explicit grid update is four lines of code, and is stable only if αΔt/Δx² ≤ ¼. - [Particle System](https://gdcs.coody.app/systems/patterns/particle-system.md) — An emitter spawns particles at a rate, each lives for a set time under simple forces, and all of them render in a single instanced draw call. ## Further reading - [OpenStax University Physics Vol. 2 — Pressure, Temperature, and RMS Speed](https://openstax.org/books/university-physics-volume-2/pages/2-2-pressure-temperature-and-rms-speed) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # 2D Schrödinger Wave Packet > A Gaussian wave packet in 2D drifts, spreads and splits at a potential bump; brightness shows |ψ|² and colour shows the quantum phase. - Category: Physics › Quantum Mechanics - Level: advanced - Tags: quantum, schrodinger, wave-packet, scattering, pde - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/quantum-mechanics/schrodinger-2d ## What it is In two dimensions the wavefunction `ψ(x, y, t)` is a complex field over the plane. Its magnitude squared `|ψ|²` is the probability density (where the particle is likely to be found), and its phase `arg ψ` encodes momentum: phase stripes perpendicular to the motion, spaced one de Broglie wavelength apart. A **Gaussian wave packet** is the most localized state you can build. It moves at the group velocity `ħk/m` and spreads as it goes, because its momentum components travel at different speeds. A narrower start spreads faster, which is the uncertainty principle at work. When it meets a potential bump, part of it is reflected, part goes through, and the pieces carry curved phase fronts. To get this exactly you have to solve the PDE on a grid. Split-step Fourier and Crank–Nicolson are the usual unconditionally stable choices. The visual here is an **illustrative** model: the packet motion, spreading and split are scripted from analytic pieces rather than integrated. Start from the 1D [particle in a box](https://gdcs.coody.app/physics/quantum-mechanics/particle-in-box) and [tunneling](https://gdcs.coody.app/physics/quantum-mechanics/quantum-tunneling) to see the exact results this scene stylizes. ## Key formulas - **Time-dependent Schrödinger equation (2D):** $i\hbar\,\partial_t \psi = -\frac{\hbar^2}{2m}\left(\partial_x^2 + \partial_y^2\right)\psi + V(x,y)\,\psi$ - **Initial Gaussian packet:** $\psi_0(x,y) = \frac{1}{\sqrt{2\pi}\,\sigma_0}\, e^{-\frac{(x-x_0)^2 + (y-y_0)^2}{4\sigma_0^2}}\, e^{i k_0 x}$ - **Free spreading:** $\sigma(t) = \sigma_0 \sqrt{1 + \left(\frac{\hbar t}{2 m \sigma_0^2}\right)^2}$ - **Normalization and momentum:** $\iint |\psi|^2\,dx\,dy = 1,\qquad p = \hbar k_0,\quad \lambda = \frac{2\pi}{k_0}$ ## Rules of thumb - Resolve the wavelength: `k_0·Δx ≤ 1` (6+ grid points per wavelength), or the phase aliases and the packet stalls. - Split-step Fourier is exact for free motion and norm-preserving. Step size is limited by accuracy, not stability. - Pad the domain or add an absorbing layer (complex potential) at the edges, or the packet wraps or reflects back. - CPU grids of 64² update easily at 60 fps; a real 256² solver belongs in a GPU compute or ping-pong shader. - Check that ∑|ψ|²·Δx·Δy stays at 1 to within 1e-6 per step, which catches most integrator bugs. ## Implementation Write |ψ|² into heights and phase into colour, in place (no per-frame allocation): ```ts // pos: xyz per vertex of a grid in the XZ plane; col: rgb per vertex. export function paintPacket( pos: Float32Array, col: Float32Array, x0: number, sigma: number, k0: number, t: number, a: [number, number, number], b: [number, number, number] // phase ramp ) { const inv = 1 / (2 * sigma * sigma) for (let i = 0; i < pos.length; i += 3) { const x = pos[i], z = pos[i + 2] const rho = Math.exp(-((x - x0) ** 2 + z * z) * inv) // |ψ|², peak 1 const phase = k0 * x - 0.5 * k0 * k0 * t // free plane-wave phase const m = 0.5 + 0.5 * Math.cos(phase) pos[i + 1] = rho * 1.7 for (let c = 0; c < 3; c++) col[i + c] = (a[c] + (b[c] - a[c]) * m) * rho } } ``` ## Common mistakes - Integrating with explicit Euler: the norm grows every step and the field blows up, whatever Δt you pick. - Plotting Re ψ and calling it the density. Use |ψ|², and show the phase separately with hue or a second ramp. - Undersampling the carrier wave (k₀Δx > 1), which makes the packet drift at the wrong speed. - Letting the packet hit hard grid edges and reading the bounce as physics. ## Interactive visual Surface height and brightness are |ψ|², and the cyan-to-violet stripes are the phase fronts. The red ring is the circular potential. Raise the potential strength to reflect more of the packet, and widen the initial spread to slow its spreading. The split is illustrative, not a PDE solve. ## Prompt ```text Build a 2D time-dependent Schrödinger solver in TypeScript using the split-step Fourier method on an N×N periodic grid (N a power of two, ħ = m = 1). Inputs: grid size, domain length, Δt, a potential V(x,y) (Float32Array), and an initial Gaussian packet (x0, y0, σ0, k0). Each step: half-step potential phase e^{−iVΔt/2}, FFT, kinetic phase e^{−ik²Δt/2}, inverse FFT, half-step potential. Use preallocated Float32Array real/imag buffers. Render |ψ|² as heights of a Three.js PlaneGeometry (≤ 64² vertices) and phase as colour. Add an absorbing edge layer. Tests: norm stays 1 ± 1e-6 over 500 steps with V = 0, and a free packet's width follows σ(t) = σ0√(1 + (t/(2σ0²))²) within 2 %. ``` ## Related - [Quantum Tunneling](https://gdcs.coody.app/physics/quantum-mechanics/quantum-tunneling.md) — A quantum packet hitting a barrier higher than its energy still partly gets through: transmission falls off exponentially with barrier width. - [Particle in a Box](https://gdcs.coody.app/physics/quantum-mechanics/particle-in-box.md) — A particle trapped between hard walls can only hold standing waves, so its energy is quantized as n² and superpositions slosh at beat frequencies. - [Heat Diffusion](https://gdcs.coody.app/physics/thermodynamics/heat-diffusion.md) — Heat flows down temperature gradients at a rate set by the Laplacian. The explicit grid update is four lines of code, and is stable only if αΔt/Δx² ≤ ¼. ## Further reading - [OpenStax University Physics Vol. 3 — The Schrödinger Equation](https://openstax.org/books/university-physics-volume-3/pages/7-3-the-schrodinger-equation) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Particle in a Box > A particle trapped between hard walls can only hold standing waves, so its energy is quantized as n² and superpositions slosh at beat frequencies. - Category: Physics › Quantum Mechanics - Level: common - Tags: quantum, schrodinger, eigenstates, superposition, standing-waves - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/quantum-mechanics/particle-in-box ## What it is Put a particle between two infinitely high walls a distance `L` apart. The wavefunction must vanish at both walls, so only waves that fit a whole number of half-wavelengths survive: `sin(nπx/L)` for `n = 1, 2, 3…`. Each of these **eigenstates** has a fixed energy that grows as `n²`. A single eigenstate is *stationary*: its phase spins at a rate set by its energy, but `|ψ|²` never changes. Mix two eigenstates and the phases drift apart. Their interference makes the probability density slosh from wall to wall at the **beat frequency** `(E_n − E_1)/ħ`. This is the simplest model of quantization (electrons in a quantum dot, conjugated molecules, a guitar string's harmonics) and a good first test case for any [Schrödinger](https://gdcs.coody.app/physics/quantum-mechanics/schrodinger-2d) solver, because the answer is exact. Real walls are finite, which is where [tunneling](https://gdcs.coody.app/physics/quantum-mechanics/quantum-tunneling) comes in. ## Key formulas - **Eigenstates:** $\psi_n(x) = \sqrt{\tfrac{2}{L}}\,\sin\!\left(\frac{n\pi x}{L}\right),\quad 0 \le x \le L$ - **Energy levels:** $E_n = \frac{n^2 \pi^2 \hbar^2}{2 m L^2} = n^2 E_1$ - **Two-state superposition:** $\Psi(x,t) = a_1 \psi_1 e^{-iE_1 t/\hbar} + a_n \psi_n e^{-iE_n t/\hbar},\quad |a_1|^2 + |a_n|^2 = 1$ - **Beat period of the density:** $T_{\text{beat}} = \frac{2\pi\hbar}{E_n - E_1} = \frac{2\pi\hbar}{(n^2-1)E_1}$ — With even n the density sloshes left–right; odd n keeps ⟨x⟩ = L/2 by symmetry. ## Rules of thumb - Natural units (`ħ = m = L = 1`, energies in `E_1`) keep the numbers near 1; the visual uses them. - An electron in a 1 nm box has `E_1 ≈ 0.38 eV`, so the n = 1 → 2 gap is ≈ 1.1 eV (near-infrared light). - Sample at least ~20 points per half-wave: n = 6 needs ≥ 120 samples across the box to look smooth. - Amplitudes are √(probability): a 45 % excited mix means `a_n = √0.45 ≈ 0.67`, not 0.45. ## Implementation Probability density of a two-level superposition, written into a reusable buffer: ```ts // Natural units: ħ = m = L = 1, energies in units of E1, so E_n = n². export function boxDensity( out: Float32Array, // length = samples t: number, n: number, mix: number // probability in the excited state, 0..1 ) { const a1 = Math.sqrt(1 - mix) const an = Math.sqrt(mix) const c1 = Math.cos(t), s1 = Math.sin(t) const cn = Math.cos(n * n * t), sn = Math.sin(n * n * t) for (let i = 0; i < out.length; i++) { const x = i / (out.length - 1) const p1 = Math.SQRT2 * a1 * Math.sin(Math.PI * x) const pn = Math.SQRT2 * an * Math.sin(n * Math.PI * x) const re = p1 * c1 + pn * cn const im = -(p1 * s1 + pn * sn) out[i] = re * re + im * im // integrates to 1 over [0, 1] } return out } ``` ## Common mistakes - Using the mix probability as the amplitude, which breaks normalization; amplitudes are square roots. - Animating each eigenstate's density on its own: a single eigenstate's |ψ|² never moves; only the cross term oscillates. - Giving every level the same phase speed. The phase rate is E_n/ħ ∝ n², which sets the beat frequency. - Forgetting the √(2/L) normalization when changing L, so the total probability drifts away from 1. ## Interactive visual The cyan curve is the probability density |Ψ|², the orange curve above it is Re Ψ, and the yellow cone marks the mean position ⟨x⟩. Raise the excited-state mix or pick an even level n to make the density slosh between the grey walls; the readout gives the beat period. ## Prompt ```text Write a TypeScript module plus a Three.js visual for a particle in an infinite square well. Use natural units (ħ = m = L = 1, E_n = n²). Export boxDensity(out: Float32Array, t, n, mix) that fills |Ψ(x,t)|² for Ψ = √(1−mix)·ψ1·e^{−it} + √mix·ψn·e^{−in²t} with ψk = √2·sin(kπx), allocation-free. Render the density as a line between two wall meshes and move a marker to ⟨x⟩ each frame by updating buffers in place. Clamp n to integers 2–6 and mix to 0–0.95. Write unit tests: the density integrates to 1 (trapezoid rule, 1e-3 tolerance) at several times, and with mix = 0 it is time-independent. ``` ## Related - [Quantum Tunneling](https://gdcs.coody.app/physics/quantum-mechanics/quantum-tunneling.md) — A quantum packet hitting a barrier higher than its energy still partly gets through: transmission falls off exponentially with barrier width. - [2D Schrödinger Wave Packet](https://gdcs.coody.app/physics/quantum-mechanics/schrodinger-2d.md) — A Gaussian wave packet in 2D drifts, spreads and splits at a potential bump; brightness shows |ψ|² and colour shows the quantum phase. - [Spring-Mass Oscillator](https://gdcs.coody.app/physics/mechanics/spring-mass-oscillator.md) — Hooke's law, damping and a periodic push: the forced oscillator behind springy cameras, wobbly UI and suspension, and why driving at ω₀ explodes. ## Further reading - [OpenStax University Physics Vol. 3 — The Quantum Particle in a Box](https://openstax.org/books/university-physics-volume-3/pages/7-4-the-quantum-particle-in-a-box) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Quantum Tunneling > A quantum packet hitting a barrier higher than its energy still partly gets through: transmission falls off exponentially with barrier width. - Category: Physics › Quantum Mechanics - Level: advanced - Tags: quantum, tunneling, barrier, transmission, wave-packet - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/quantum-mechanics/quantum-tunneling ## What it is Classically, a ball with energy `E` cannot cross a hill of height `V > E`. A quantum particle is a wave, and inside the barrier its wavefunction does not stop dead. It decays exponentially as `e^(−κx)`. If the barrier is thin enough, some amplitude is left on the far side and continues as a transmitted wave. For a rectangular barrier the transmission probability `T` has an exact closed form. Below the top (`E < V`) it is dominated by `e^(−2κa)`, so doubling the width squares the suppression. Above the top (`E > V`) `T` is still not 1: the packet partly reflects off the step and shows resonances where whole half-waves fit inside the barrier. Tunneling explains alpha decay, scanning tunneling microscopes, flash memory and tunnel diodes. In code it is a good stress test for a [Schrödinger](https://gdcs.coody.app/physics/quantum-mechanics/schrodinger-2d) solver: compare your measured `T` against the formula. It is unrelated to [physics-engine tunneling](https://gdcs.coody.app/systems/collision/tunneling), where fast objects skip through thin colliders between steps. ## Key formulas - **Time-dependent Schrödinger equation (1D):** $i\hbar\,\frac{\partial \psi}{\partial t} = -\frac{\hbar^2}{2m}\frac{\partial^2 \psi}{\partial x^2} + V(x)\,\psi$ - **Decay constant inside the barrier:** $\kappa = \frac{\sqrt{2m(V - E)}}{\hbar}$ - **Exact transmission, E < V:** $T = \left[1 + \frac{V^2 \sinh^2(\kappa a)}{4E(V-E)}\right]^{-1}$ - **Opaque-barrier limit:** $T \approx 16\,\frac{E}{V}\left(1 - \frac{E}{V}\right) e^{-2\kappa a}\quad (\kappa a \gg 1)$ - **Above the barrier, E > V:** $T = \left[1 + \frac{V^2 \sin^2(k a)}{4E(E-V)}\right]^{-1},\quad k = \frac{\sqrt{2m(E-V)}}{\hbar}$ — T = 1 exactly when ka = nπ (transmission resonance). ## Rules of thumb - In natural units (`ħ = m = 1`) κ = √(2(V − E)); κa ≳ 3 already means T below about 1 %. - Handle E = V separately: the limit is T = 1 / (1 + mVa²/(2ħ²)), and the general formula divides by zero there. - Electron, 1 eV below the top of a 1 nm barrier: κ ≈ 5.1 nm⁻¹, T ~ 10⁻⁴. At 0.5 nm it rises to ~10⁻². - A packet's group velocity is ħk/m = √(2E/m). Faster packets carry more energy and tunnel more. ## Implementation Rectangular-barrier transmission (ħ = m = 1), safe at E = V: ```ts export function transmission(E: number, V: number, a: number): number { if (E <= 0) return 0 if (Math.abs(E - V) < 1e-9) return 1 / (1 + (V * a * a) / 2) if (E < V) { const kappa = Math.sqrt(2 * (V - E)) const s = Math.sinh(kappa * a) return 1 / (1 + (V * V * s * s) / (4 * E * (V - E))) } const k = Math.sqrt(2 * (E - V)) const s = Math.sin(k * a) return 1 / (1 + (V * V * s * s) / (4 * E * (E - V))) } // Reflection is whatever is not transmitted (probability is conserved). export const reflection = (E: number, V: number, a: number) => 1 - transmission(E, V, a) ``` ## Common mistakes - Assuming everything passes when E > V. Above-barrier reflection is real and oscillates with width. - Using sinh(κa) for large κa without care: it overflows near κa ≈ 710. Switch to the exponential approximation first. - Scaling the packet's height with its energy. |ψ|² integrates to 1 whatever the energy. - Calling a barrier's T a 'speed' or a delay. It is a probability, measured over many particles. ## Interactive visual The cyan curve is the packet's probability density riding on the dashed yellow energy line, and the red block is the barrier (height V, width a). Lower the energy below the barrier top or widen the barrier and the transmitted bump shrinks; the readout shows the exact T and R. ## Prompt ```text In TypeScript, implement transmission(E, V, a) for a 1D rectangular potential barrier in natural units (ħ = m = 1), covering E < V (sinh form), E > V (sin form) and the E = V limit, returning 0 for E ≤ 0. Then build a Three.js visual: a translucent barrier box of height V and width a, a dashed line at height E, and a Gaussian |ψ|² curve drawn on that line that travels at speed √(2E) and splits into a transmitted part (scaled by T) and a reflected mirror image (scaled by 1−T) at the barrier's left face, updating a preallocated buffer each frame. Unit-test that T + R = 1, that T decreases monotonically with a when E < V, that T = 1 at ka = π for E > V, and continuity across E = V within 1e-4. ``` ## Related - [Particle in a Box](https://gdcs.coody.app/physics/quantum-mechanics/particle-in-box.md) — A particle trapped between hard walls can only hold standing waves, so its energy is quantized as n² and superpositions slosh at beat frequencies. - [2D Schrödinger Wave Packet](https://gdcs.coody.app/physics/quantum-mechanics/schrodinger-2d.md) — A Gaussian wave packet in 2D drifts, spreads and splits at a potential bump; brightness shows |ψ|² and colour shows the quantum phase. - [Tunneling & Continuous Collision](https://gdcs.coody.app/systems/collision/tunneling.md) — A fast object can be on one side of a thin wall at one tick and past it at the next. Swept tests (CCD) check the whole path between positions. ## Further reading - [OpenStax University Physics Vol. 3 — Quantum Tunneling of Particles through Potential Barriers](https://openstax.org/books/university-physics-volume-3/pages/7-6-the-quantum-tunneling-of-particles-through-potential-barriers) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Gravitational Waves > A compact binary radiates gravitational waves at twice its orbital frequency; chirp mass and distance set a strain of around 10⁻²¹ or smaller. - Category: Physics › Relativity - Level: advanced - Tags: relativity, gravitational-waves, binary, chirp-mass, strain - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/relativity/gravitational-waves ## What it is When masses accelerate asymmetrically, as two stars or black holes in a tight orbit do, the changing curvature travels outward at the speed of light. A passing wave stretches distances along one axis and squeezes them along the perpendicular one. The fractional change in length is the **strain** `h`. A binary looks the same after half an orbit, so its quadrupole pattern repeats twice per orbit and **f_GW = 2 f_orb**. The amplitude depends on the masses only through the **chirp mass** `M_c` and falls off as 1/distance. As the system loses energy the orbit shrinks and the frequency and amplitude rise together: the characteristic chirp. Even the loudest events arrive with `h ~ 10⁻²¹`: a 4 km detector arm changes length by about 1/400 of a proton width. The visual shows the binary's potential wells (see [mass effect](https://gdcs.coody.app/physics/relativity/mass-effect)) plus a strongly exaggerated, phase-locked spiral of strain. The physical numbers are in the readout. ## Key formulas - **Chirp mass:** $\mathcal{M}_c = \frac{(m_1 m_2)^{3/5}}{(m_1 + m_2)^{1/5}}$ - **Strain amplitude (circular, leading order):** $h \approx \frac{4}{r}\left(\frac{G\mathcal{M}_c}{c^2}\right)^{5/3}\left(\frac{\pi f_{\text{GW}}}{c}\right)^{2/3}$ - **Separation from Kepler's third law:** $a = \left(\frac{G(m_1+m_2)}{(2\pi f_{\text{orb}})^2}\right)^{1/3},\qquad f_{\text{GW}} = 2 f_{\text{orb}}$ - **Chirp rate:** $\dot f = \frac{96}{5}\,\pi^{8/3}\left(\frac{G\mathcal{M}_c}{c^3}\right)^{5/3} f^{11/3}$ - **Wave pattern used in the visual:** $h(r,\varphi,t) \propto \frac{1}{r}\cos\!\big(2\Phi(t) - 2\varphi - kr\big)$ — Φ is the orbital phase: the two-armed spiral rotates with the binary. ## Rules of thumb - Two 1.4 M☉ neutron stars: M_c ≈ 1.22 M☉. At f_orb = 1 Hz they are ~2,100 km apart and h ≈ 2.6×10⁻²⁴ at 100 Mpc. - Ground detectors hear ~10 Hz–few kHz. Space interferometers target ~0.1 mHz–0.1 Hz, the band of supermassive binaries. - Compute h in log space: (GM_c/c²)^(5/3) and (πf/c)^(2/3) have very different magnitudes. - Cap animation playback (here 0.6 orbits/s). A 100 Hz orbit drawn at 60 fps aliases into nonsense. ## Implementation Chirp mass and leading-order strain, computed in log space to avoid under/overflow: ```ts const G = 6.6743e-11, C = 299_792_458 export function chirpMass(m1: number, m2: number) { return Math.pow(m1 * m2, 3 / 5) / Math.pow(m1 + m2, 1 / 5) } // Masses in kg, distance in m, f = gravitational-wave frequency (2 × orbital). export function strain(m1: number, m2: number, distance: number, f: number) { if (f <= 0) return 0 const mc = chirpMass(m1, m2) const log = Math.log(4) + (5 / 3) * Math.log((G * mc) / (C * C)) + (2 / 3) * Math.log((Math.PI * f) / C) - Math.log(distance) return Math.exp(log) } ``` ## Common mistakes - Using the orbital frequency where the gravitational-wave frequency (twice as high) is required. - Drawing waves as a plane wave unrelated to the source. The pattern is a two-armed spiral locked to the orbit. - Plotting physical strain directly. At 10⁻²¹ nothing moves on screen, so state the exaggeration factor. - Multiplying (m1·m2)^(3/5) in kilograms directly. It is fine in doubles, but the full strain product is better taken in logs. ## Interactive visual The orange and yellow bodies orbit inside their potential wells, and the cyan-to-violet ripples are the exaggerated strain spiralling outward at twice the orbital rate. Change the two masses to shift the barycentre and deepen the wells, and the visual strain scale to exaggerate the ripples; the readout gives the real strain at 100 Mpc. ## Prompt ```text Write TypeScript utilities chirpMass(m1, m2) and strain(m1, m2, distance, fGW) for the leading-order gravitational-wave amplitude h = (4/r)(GM_c/c²)^{5/3}(πf/c)^{2/3}, computed in log space, plus keplerSeparation(m1, m2, fOrb). Then build a Three.js visual: a ≤ 64² PlaneGeometry updated in place each frame whose height combines two clamped −m/r wells at the barycentric positions of the binary and an exaggerated strain h ∝ cos(2Φ − 2φ − kr)/r, coloured by h; cap the displayed orbit rate at 0.6 Hz. Unit-test that the chirp mass of two 1.4 M☉ stars ≈ 1.219 M☉, that h ∝ 1/r and ∝ f^{2/3}, and that strain returns 0 at f = 0. ``` ## Related - [Mass Effect on Spacetime](https://gdcs.coody.app/physics/relativity/mass-effect.md) — Mass curves spacetime: picture a gravity well whose depth follows −GM/r, with the Schwarzschild radius 2GM/c² marking where light can no longer escape. - [Mercury's Perihelion Precession](https://gdcs.coody.app/physics/relativity/mercury-precession.md) — General relativity adds a small 1/r⁴ pull that makes an orbit's closest point creep forward each lap: about 43 arcseconds per century for Mercury. - [EM Wave Propagation](https://gdcs.coody.app/physics/electromagnetism/em-wave-propagation.md) — Light is E and B regenerating each other. A 1D Yee grid (FDTD) leapfrogs them in time, slows them in glass and blows up past the Courant limit. ## Further reading - [LIGO Caltech — What are Gravitational Waves?](https://www.ligo.caltech.edu/page/what-are-gw) - [OpenStax University Physics Vol. 1 — Einstein's Theory of Gravity](https://openstax.org/books/university-physics-volume-1/pages/13-8-einsteins-theory-of-gravity) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Mass Effect on Spacetime > Mass curves spacetime: picture a gravity well whose depth follows −GM/r, with the Schwarzschild radius 2GM/c² marking where light can no longer escape. - Category: Physics › Relativity - Level: common - Tags: relativity, gravity, schwarzschild, potential, black-hole - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/relativity/mass-effect ## What it is General relativity says mass and energy curve spacetime, and free bodies follow the straightest possible paths through that curved geometry. Far from a mass, the effect matches Newton: the gravitational potential is `Φ = −GM/r`, and orbits, tides and escape speeds all follow from it. The familiar **rubber-sheet** picture plots that potential as a surface: the deeper the dip, the stronger the pull. It is an *analogy*. The real curvature lives in four dimensions, and a lot of it is the slowing of clocks rather than the bending of space. It still gives the right intuition: more mass makes a deeper, wider well and faster orbits at a given radius. Squeeze a mass inside its **Schwarzschild radius** `r_s = 2GM/c²` and the escape speed reaches `c`, so you get a black hole. For the Sun `r_s ≈ 2.95 km`, so it scales at about 3 km per solar mass. Orbits deep in the well are no longer closed ellipses: see [Mercury's precession](https://gdcs.coody.app/physics/relativity/mercury-precession). Accelerating masses send ripples through the geometry: see [gravitational waves](https://gdcs.coody.app/physics/relativity/gravitational-waves). ## Key formulas - **Schwarzschild radius:** $r_s = \frac{2GM}{c^2}$ - **Newtonian potential (weak field):** $\Phi(r) = -\frac{GM}{r}$ - **Escape velocity:** $v_{\text{esc}} = \sqrt{\frac{2GM}{r}} = c\,\sqrt{\frac{r_s}{r}}$ - **Gravitational time dilation (static clock):** $\frac{d\tau}{dt} = \sqrt{1 - \frac{r_s}{r}}$ - **Circular orbit speed and rate:** $v = \sqrt{\frac{GM}{r}},\qquad \omega = \sqrt{\frac{GM}{r^3}}$ ## Rules of thumb - r_s ≈ 2.95 km × (M / M☉). Earth's is about 8.9 mm. - Weak-field (Newtonian) is fine when r ≫ r_s. GPS clocks at r/r_s ≈ 3×10⁹ still need a ~45 µs/day correction. - For a well visual, clamp the radius (here r ≥ 0.5) so the 1/r spike stays finite, and add a falloff such as e^(−r/5) so the edges are flat. - A 40×40 wireframe grid (1.7k vertices) is plenty. Rebuild it only when mass changes, not every frame. ## Implementation Build a gravity-well grid once per mass change (Three.js): ```ts import * as THREE from "three" const G = 6.6743e-11, C = 299_792_458, M_SUN = 1.989e30 export const schwarzschildRadius = (kg: number) => (2 * G * kg) / (C * C) // Stylized potential: −M/r, clamped near the centre, faded towards the edges. export function wellGeometry(solarMasses: number, size = 20, res = 40) { const geo = new THREE.PlaneGeometry(size, size, res, res) const p = geo.attributes.position.array as Float32Array for (let i = 0; i < p.length; i += 3) { const r = Math.max(Math.hypot(p[i], p[i + 1]), 0.5) p[i + 2] = (-solarMasses / r) * Math.exp(-r / 5) } geo.computeVertexNormals() return geo // rotate −90° about X so the dip points down } schwarzschildRadius(M_SUN) // ≈ 2953 m ``` ## Common mistakes - Reading the rubber sheet literally. It is a picture of the potential, not the shape of space, and it needs gravity to explain itself. - Calling r_s the size of the star. It is only an event horizon if all the mass is inside it. - Rebuilding a high-resolution grid every frame when only the mass slider changed. - Mixing kilograms and solar masses in one formula. Convert once at the boundary. ## Interactive visual The cyan wireframe is the gravity-well analogy, the yellow sphere is the central mass, and the orange probe orbits faster as the well deepens. Drag the mass slider to deepen the well and switch the well off to compare with flat space; the readout gives the Schwarzschild radius. ## Prompt ```text Create a Three.js + TypeScript gravity-well visual. Input: mass in solar masses (0.1–10) and grid resolution (10–40). Build a PlaneGeometry (20×20 units) whose vertex height is −M/max(r, 0.5)·e^(−r/5), rotated so the dip points down, and render it as a wireframe; rebuild the geometry only when inputs change and dispose the old one. Place a sphere at the well bottom and a probe orbiting at r = 4.2 with angular speed ∝ √(M/r³), moved in useFrame without allocations. Show r_s = 2GM/c² in km. Unit-test schwarzschildRadius(1 M☉) ≈ 2953 m (±1 m) and that well depth scales linearly with mass. ``` ## Related - [Mercury's Perihelion Precession](https://gdcs.coody.app/physics/relativity/mercury-precession.md) — General relativity adds a small 1/r⁴ pull that makes an orbit's closest point creep forward each lap: about 43 arcseconds per century for Mercury. - [Gravitational Waves](https://gdcs.coody.app/physics/relativity/gravitational-waves.md) — A compact binary radiates gravitational waves at twice its orbital frequency; chirp mass and distance set a strain of around 10⁻²¹ or smaller. - [Kepler's Laws](https://gdcs.coody.app/physics/mechanics/keplers-laws.md) — Orbits are ellipses with the star at one focus, sweep equal areas in equal times, and have T² ∝ a³. Place planets on rails exactly with Kepler's equation. ## Further reading - [OpenStax University Physics Vol. 1 — Einstein's Theory of Gravity](https://openstax.org/books/university-physics-volume-1/pages/13-8-einsteins-theory-of-gravity) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app) # Mercury's Perihelion Precession > General relativity adds a small 1/r⁴ pull that makes an orbit's closest point creep forward each lap: about 43 arcseconds per century for Mercury. - Category: Physics › Relativity - Level: advanced - Tags: relativity, orbits, precession, rk4, mercury - Updated: 2026-11-01 - URL: https://gdcs.coody.app/physics/relativity/mercury-precession ## What it is In pure Newtonian gravity a lone planet traces a closed ellipse forever, returning to the same perihelion (closest point) every orbit. Mercury doesn't. Its perihelion advances about 574″ per century. Pulls from the other planets explain ~531″, and the leftover **~43″/century** puzzled astronomers for decades until general relativity predicted it exactly. Near a mass, the Schwarzschild geometry adds a correction to the effective force proportional to `L²/r⁴`, where `L` is the orbit's angular momentum per unit mass. It is strongest at perihelion. The orbit becomes a slowly rotating ellipse, a rosette, advancing by `Δω = 6πGM/(c²a(1−e²))` per lap. That is tiny for Mercury and huge for stars orbiting the galactic-centre black hole. Real numbers would never show up on screen, so the visual uses **sim units**: G = 1, M in solar masses, a = 8, and a reduced c = 8. That makes Δω a few degrees per orbit while keeping v ≪ c. The integrator is fixed-step RK4 (see [fixed timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep)), and the readout still checks the real Sun–Mercury value in SI units. Compare with the closed orbits of [Kepler's laws](https://gdcs.coody.app/physics/mechanics/keplers-laws). ## Key formulas - **Precession per orbit:** $\Delta\omega = \frac{6\pi G M}{c^2 a (1 - e^2)}\ \text{rad/orbit}$ - **Weak-field (1PN) radial acceleration:** $\ddot{\mathbf r} = -\frac{GM}{r^2}\,\hat{\mathbf r}\left(1 + \frac{3L^2}{c^2 r^2}\right),\qquad L = |\mathbf r \times \dot{\mathbf r}|$ - **Vis-viva (launch speed):** $v^2 = GM\left(\frac{2}{r} - \frac{1}{a}\right)$ - **Orbital period:** $T = 2\pi\sqrt{\frac{a^3}{GM}}$ - **Mercury check:** $\Delta\omega \approx 0.1035''\ \text{per orbit} \times 415\ \text{orbits} \approx 43''\ \text{per century}$ ## Rules of thumb - Mercury: a = 5.791×10¹⁰ m, e = 0.206, 415 orbits per century, giving 42.98″/century from GR. - Use RK4 or a symplectic integrator at a fixed dt. Here dt = 1/120 sim-second keeps energy drift under 1e-8 over 30k steps. - Detect perihelion as a local minimum of r between consecutive steps, then take atan2 to measure the advance. - Speed up time by doing more fixed substeps per frame, never by growing dt, and clamp the accumulator so slow frames can't spiral. - Keep v/c at perihelion below ~0.1 in scaled units, or the 1PN correction stops being a small perturbation. ## Implementation Allocation-free acceleration with the GR term, for an RK4 step on [x, y, vx, vy]: ```ts const GM = 1, C = 8 // sim units export function accel(s: Float64Array, gr: boolean, out: Float64Array) { const x = s[0], y = s[1], vx = s[2], vy = s[3] const r2 = x * x + y * y const r = Math.sqrt(r2) const L = x * vy - y * vx // specific angular momentum (z) const boost = gr ? 1 + (3 * L * L) / (C * C * r2) : 1 const k = (-GM * boost) / (r2 * r) out[0] = vx out[1] = vy out[2] = k * x out[3] = k * y } // Each frame: for (n = 0; n < timeScale; n++) rk4(state, 1 / 120) // Perihelion: prevR < prevPrevR && prevR < r → angle = atan2(y, x) ``` ## Common mistakes - Using real SI constants at render scale. At a = 8 m the orbit becomes superluminal and blows up. - Integrating with explicit Euler: its spurious energy drift precesses the orbit too and swamps the GR signal. - Speeding up time by enlarging dt instead of adding substeps, which changes the physics with the slider. - Keeping too short a trail. The rosette only shows when the trail spans several orbits. - Forgetting the 1/(1 − e²) factor: eccentric orbits precess more per lap. ## Interactive visual The cyan trail is the planet's (orange) last few orbits around the yellow star, the dashed grey ellipse is the closed Newtonian orbit, and the yellow line points at the latest perihelion. Toggle general relativity to watch the rosette appear or vanish, and raise the star mass or eccentricity for faster precession. ## Prompt ```text Implement a Mercury-style perihelion precession demo in TypeScript + Three.js. Use sim units (G = 1, M in solar masses, a = 8, c = 8). Write an allocation-free RK4 step on a Float64Array state [x, y, z, vx, vy, vz] with acceleration −GM r̂/r²·(1 + 3L²/(c²r²)) when GR is on. Launch at perihelion r0 = a(1−e) with vis-viva speed, and run fixed 1/120 s substeps (time scale = substeps per fixed tick) with a clamped accumulator. Draw a 720-point ring-buffered trail spanning ~4 orbits, a dashed Newtonian reference ellipse, and a line to the latest perihelion. Unit tests: real Mercury precession ≈ 42.98″/century, energy drift < 1e-8 over 30k classical steps, a classical orbit's perihelion stays put, and the measured GR advance over 3 orbits is within 5 % of 6πGM/(c²a(1−e²)). ``` ## Related - [Kepler's Laws](https://gdcs.coody.app/physics/mechanics/keplers-laws.md) — Orbits are ellipses with the star at one focus, sweep equal areas in equal times, and have T² ∝ a³. Place planets on rails exactly with Kepler's equation. - [Mass Effect on Spacetime](https://gdcs.coody.app/physics/relativity/mass-effect.md) — Mass curves spacetime: picture a gravity well whose depth follows −GM/r, with the Schwarzschild radius 2GM/c² marking where light can no longer escape. - [Fixed Timestep](https://gdcs.coody.app/systems/simulation/fixed-timestep.md) — Run physics in constant dt slices from an accumulator, clamp long frames, and interpolate between the last two states for smooth, repeatable motion. ## Further reading - [OpenStax University Physics Vol. 1 — Einstein's Theory of Gravity](https://openstax.org/books/university-physics-volume-1/pages/13-8-einsteins-theory-of-gravity) - [Gaffer On Games — Integration Basics](https://gafferongames.com/post/integration_basics/) --- Source: Game Development Cheat Sheet (https://gdcs.coody.app)