TeamSpecWishlist on Steam

Update 7/5/2026: the story didn't end where this article originally ended. New sections starting at "Update, one month later" cover the rewrite that followed. The shape survived it; the collision test underneath it did not.

Introduction

I've thought about character collision/physics in TeamSpec more than basically any other system in the game, with the exception of networking. It's the part the player feels every single frame, but it's also the part that's hardest to get right because every shape has tradeoffs and every engine handles every shape differently. This is the story of how I picked the shape I ended up with: a 16-sided convex polygon hull that approximates a cylinder, and all the things I tried along the way.

Capsule (the easy default)

When I started playing with Godot (way back in 2020), I used a CapsuleShape3D. This is what basically every Unity / Unreal game uses for the player. It is the industry standard for modern games. A capsule is a cylinder with a hemisphere on top and a hemisphere on the bottom, and engines love capsules because the shape is mathematically simple, almost as simple as a mere sphere. Every point on the surface is the same distance from the central segment, so a lot of collision math collapses to "distance from segment less than radius." Jolt's CapsuleShape is extremely fast. In my own benchmark, a single capsule character costs about 0.028 ms per body per tick, and I'm treating that as the 1.0× baseline.

The reason I didn't stick with a capsule is the rounded 'foot'. The hemisphere at the feet means the contact point with the ground is exactly one point: the bottom of the sphere. When you walk up to a small step or up the edge of a ramp, the capsule pivots around that contact point and the engine tells you you're standing on the ground at a Z that's a few centimeters off from where the visual feet are. More damning, when you stand near a sharp edge, the capsule's rounded bottom slides off it the way a marble slides off the corner of a table. You can't stand on a 2-inch ledge with a capsule and feel like you're standing on a 2-inch ledge. The feet roll off. In a shooter that has any kind of vertical level design, this feels wrong constantly.

The other problem is the hemisphere at the top. When you jump and your head clips a low ceiling, the capsule slides sideways because the round top makes the contact normal point away from vertical. You'd think you'd be hitting the ceiling head-on, but the engine's normal says you're being pushed slightly sideways and your jump arc deflects. This is fixable with a ceiling-specific check, but the fact that I have to write a special case for it means the shape is wrong for the game I'm building.

Cylinder (the "right" shape, in theory)

A cylinder is what I actually want geometrically. Flat top, flat bottom, you can stand on a ledge whose width matches your radius, your head hits the ceiling vertically, no rolling-off-corners, no deflections. The problem is that cylinders are an absolute nightmare to collide against triangles, and every physics engine implementer agrees on this. PhysX doesn't include a cylinder primitive at all because the contact generation is so unstable. Jolt does include one, but the official docs literally tell you that cylinders are the least stable of their primitives and you should consider a tapered capsule first.

I tried it anyway. My benchmark says Jolt's CylinderShape is 8.7× slower than a capsule per body per tick. That destroys our TPS budget when characters are simulated server-side. That was it. The collisions were fine, just really slow. I didn't really observe any faulty collisions when using Jolt's CylinderShape.

Box (let's just give up on being round)

After fighting with cylinders for a while, I tried a BoxShape3D. A box-vs-triangle is one of the cleanest collision queries in any engine — Jolt does it in about 2.0× the cost of a capsule, which is much more reasonable than the cylinder. It needs to have rotations turned off though, because as it rotates along the y axis, the corners of the box could go from touching something to not touching it. The source engine (and pretty much all engines that are very old and use BSP for their levels) uses an axis aligned bounding box for its player character, axis aligned meaning rotations not included.

The problem with a box is the corners. When you're running diagonally through a doorway, the diagonal width of the box is sqrt(2) times the side length, which means the box is suddenly much wider than the corridor and you catch on the doorway. You can rotate the box to match camera yaw and it helps for forward motion, but the moment you strafe around something, the corners are wider than they ought to be again. I tried rotating the box every tick to match velocity instead of camera, and that just made the box visibly spin around the player, which felt awful. The box is the only shape I've tried that actually changed the game's feel for the worse. I dropped it pretty fast.

Custom cylinder traces (the SAT saga)

At this point I was convinced the answer was a custom cylinder-vs-triangle trace. The shape would be a cylinder (geometrically) but the collision code wouldn't be Jolt's general-purpose cylinder; it would be a hand-rolled trace specialized for the fact that the cylinder is always axis-aligned to Y and never rotates. That's a HUGE simplification. The actual contact problem reduces to "distance from a 2D disc to a 3D triangle, with a 1D height check," and you can split that into a vertical step and a horizontal step.

I tried implementing it using the Separating Axis Theorem. SAT is the canonical "check if two convex shapes overlap" algorithm — you find a separating plane, if one exists nothing collides. The problem is that SAT requires the shape to actually be a polygon, because the axes to test are the face normals of the two shapes. A cylinder doesn't have face normals, it has a smooth round side. You can approximate it by treating the cylinder's normal at the closest point on its rim as "the" axis to test, but this is only valid when the triangle's closest point to the cylinder is on the rim. If the closest point is on an end cap, you need a totally different test. If the closest point is on the side wall but the triangle is parallel to the cylinder's axis, SAT degenerates. I spent two weeks fixing edge case after edge case and never got the bugs to a state I could keep.

It never ended up working well enough on a real map. It worked fine on simplistic test maps, but there were always edge cases I couldn't fix. I gave up. I sat down and actually did the research I should have done at the start. Cylinder-vs-triangle is a known-hard problem and the canonical algorithm is the Eberly slab-clip method (David Eberly's geometry book). The trick is that SAT is fundamentally the wrong tool for a smooth-curve-vs-polygon collision, and you should clip the triangle against the cylinder's two horizontal "slabs" (the planes through the top and bottom caps) and then do a 2D disc-vs-polygon test on the clipped polygon's projection. It's not actually that complicated, but it's a totally different shape of problem than SAT and the SAT implementation could not be patched into being a slab-clip implementation. The slab-clip would have been the right path. I burned a couple weeks not realizing this. That's basically what the second TeamSpec rule of engineering is for me now: spend an hour searching for the canonical algorithm BEFORE I start writing collision code. This might be explored in the future, but I don't think that it would ever be faster than just using the following approach:

16-sided convex polygon hull (what I landed on)

After all that, the thing I ended up with is the boring answer. A regular 16-sided polygon, extruded vertically into a 16-sided convex prism. You can think of it as a cylinder with the round side replaced by sixteen flat strips. It's a ConvexPolygonShape3D, which Jolt handles using its standard convex-vs-anything path that's been polished for years.

The 16-sided prism is something Jolt is fast at, because convex-vs-triangle is one of the cleanest collision queries that exists. My benchmark puts it at about 5.4× the cost of a capsule. That's more than I'd like, but it's well under half the cost of the cylinder, and Jolt's solver gives me robust, stable, predictable contact normals. No solver glitches. No catching on seams. No rolling off ledges. The 16 sides are enough that the silhouette is visually indistinguishable from a smooth cylinder, and behaviorally indistinguishable too — at the scale of player movement, the 22.5-degree facets of the polygon never produce a contact normal that's more than 11 degrees off from the "true" cylinder normal, which is way under what a player would notice.

Mostly. There's one case where the polygon approximation does show through: when the player is sliding along a wall, the slide direction can subtly change as the contact point ticks from one facet to the next. It's a tiny effect, on the order of fractions of a millimeter per tick, but in a competitive shooter where players are literally optimizing for the smoothest possible movement, it's measurable. I considered going to 32 sides, but the benchmark says that's almost 7× the capsule cost, which puts me uncomfortably close to the cylinder again. 16 is the right tradeoff for now.

Benchmark recap

For reference, these are the numbers I'm working from. The benchmark lives at tests/perf/hull_benchmark in the TeamSpec repo — it runs N character bodies through a fixed move-and-slide pattern against the same test map for a fixed number of ticks and reports the average physics cost per body per tick. The capsule is the baseline at 1.0×. All other costs are expressed as multipliers of the capsule cost on the same hardware, against the same map, in the same harness.

Shape Cost vs Capsule ms / body / tick Why I rejected it
CapsuleShape3D 1.0× 0.028 ms Rounded feet roll off ledges; rounded top deflects head off ceilings.
BoxShape3D 2.0× ~0.056 ms Diagonal corners stick out sqrt(2)× the side length and catch on doorways.
Custom SAT cylinder trace N/A abandoned SAT is structurally wrong for cylinder-vs-triangle; got bogged in edge cases for weeks.
16-sided convex prism (landed on) 5.4× ~0.151 ms — this is the one I landed on.
32-sided convex prism ~7.0× ~0.196 ms Smoother slide direction but uncomfortably close to cylinder cost; 16-sided is good enough.
CylinderShape3D (Jolt) 8.7× ~0.244 ms Slow AND glitches in real geometry. Jolt docs themselves warn against it.

The "ms / body / tick" column is just the capsule baseline times the multiplier — it's there so you can read the actual budget consequence without doing mental math. (The original version of this article botched this math, so let me redo it properly.) At 24 players at 60 Hz that's between 0.7 ms/tick (capsule) and 5.9 ms/tick (cylinder) of pure character collision work, against a 16.7 ms tick budget. The 16-sided prism comes out to 24 × 0.151 ≈ 3.6 ms, about a fifth of the budget, which is acceptable. The cylinder's 5.9 ms would eat over a third of the tick before any other server work (weapons, lag compensation, snapshots, bots) gets a slice, on top of being the shape Jolt's own docs warn about. The box would be cheaper than either but doesn't behave the way I want.

One note from the future (this is part of the 7/5/2026 update): the benchmark harness still exists and still runs, but since the July rewrite described below the mover resolves collisions through swept traces instead of Jolt body queries, so fresh runs measure the new pipeline. The numbers in the table are from the original era. The relative ranking is what drove the decision, and it hasn't changed.

Update, one month later: the shape was right, the collision test was wrong

About three weeks after publishing this article, the whole character collision system got rewritten anyway, and the punchline is that the shape survived. What got replaced is everything around it: how the game asks "what does this shape hit when it moves." The original article stops one insight short of where the system actually ended up, so here's the honest epilogue.

The symptom that would not die was wedging. Run diagonally into the corner where two walls meet, graze a stair edge at exactly the wrong height, or slide along a seam between two map triangles, and the character would occasionally stop dead (horizontal velocity just killed) or stutter against a surface it should have slid along smoothly. I fixed individual cases for weeks, and every fix was a special case, which is the signature of an unfound root cause. I even briefly swapped Jolt's CylinderShape back in to rule the shape out. The wedging didn't care. The shape was never the problem.

The problem was how contact gets reported. Godot's move_and_collide() is an overlap-based mover: it moves the body, finds the penetration at the end position, and reports a depenetration normal: "push this way to get un-stuck." Against a flat wall face, that direction happens to equal the surface normal, so everything works and you never question it. Against an edge or a corner, the cheapest way out of penetration depends on exactly how deep you ended up, and it is not the normal of the surface you actually hit. Collide-and-slide works by clipping velocity against the planes the engine reports; feed it a phantom plane and it clips your speed into oblivion. That's a wedge.

The old-school shooters never had this bug class, and the reason is structural: a Quake-lineage engine never "moves a body and checks what went wrong." It sweeps the hull through the world, and the sweep returns the time of impact and the plane of the surface you hit on the way there, not the shortest escape route from wherever you ended up. Overlap testing answers "where am I intersecting?"; swept testing answers "what did I hit, and when?" Movement code needs the second question answered. This entire article was me agonizing over the operand while the real bug was in the operator.

The overlap path had a second, nastier failure mode too. Rest a flat hull face perfectly parallel on a flat triangle and the narrowphase can occasionally report "no collision at all" (the Minkowski difference degenerates and the EPA algorithm inside the engine gives up). That's where my intermittent fall-through-the-floor bugs on crouch and uncrouch came from. The standard workaround is a fat collision margin, but the margin rounds the hull's bottom edge, and a rounded bottom edge is the capsule's ledge problem all over again. Pick your poison: fall through floors occasionally, or roll off ledges constantly.

Trace, don't push: rebuilding the mover around a swept trace

The fix was to stop asking Jolt's mover to do a job it was never designed for. The character mover got rebuilt around tracing: the same collide-and-slide, stair stepping, and ground snapping family of techniques the classic shooters have run on for decades, driven entirely by swept hull traces instead of overlap tests. It lives in native C++, because the exact same movement code has to run for the client's prediction replays and for the server's authoritative tick, and it has to be deterministic and fast in both places. My GDScript still owns the game feel (friction curves, acceleration, air-strafing, jumping) and hands the mover a desired velocity; the mover owns everything about how that velocity negotiates with solid geometry.

It's worth spelling out how different the two flows really are, because on paper they sound interchangeable. The Jolt flow behind move_and_collide() is: move the body to the destination, collect whatever it overlaps, compute a penetration depth and axis for each overlap, push the body back out, and report the push-out direction as the contact normal. That's the right design for a physics solver, which spends all day relaxing piles of dynamic bodies that are allowed to interpenetrate a little. It's the wrong design for a character, because every answer it gives describes the overlap you ended up in rather than the surface you hit. The trace flow is: before moving at all, sweep the hull along the motion and find the first time of impact and the plane it belongs to; advance the hull to just short of that point; clip the velocity against the plane; repeat with whatever motion is left. The hull never penetrates anything, so there's never an escape direction to guess, no margin fattening the shape, no flat-on-flat degenerate case, and the reported normal is the actual plane of the actual surface, every time, deterministically.

The engine work to support this is a custom swept-hull query in my Godot fork. Jolt keeps the job it's genuinely great at, storing the world's geometry and answering the broadphase question of "which triangles are anywhere near this sweep," and the query then runs a specialized convex cast of the player hull across those candidate triangles. Specialized is the important word here. The player hull never rotates and is always the same prism, and a cast written around those constraints can be simpler, faster, and much more numerically careful than a general-purpose any-shape-versus-any-shape cast. It also stops the hull just short of every surface it hits (a bit under a millimeter), which sounds like trivia but turns out to be load-bearing; more on that below.

And the shape? Still the 16-sided prism this article landed on. The sweep builds the prism analytically from a radius and a half-height, with a configurable side count; set it below 3 and it degenerates to a plain axis-aligned box, which I keep around as an A/B lever. Sixteen sides keeps everything I originally picked the shape for (same width in every direction, flat top, flat bottom), and the facets round off the wall edges that a box wedges on. The difference from June is that the prism is no longer a Jolt body being shoved around by a solver; it's the swept volume of a trace. Same silhouette, completely different way of asking the question.

The last 2.4 millimeters: standoff recovery

One failure class survived the rewrite, and it's subtle enough to be worth documenting. The trace stops about 0.8 mm short of every surface, so a move never begins flush against geometry, and the mover implicitly relies on that little gap existing everywhere. Every ordinary hit preserves it. But a networked game keeps writing positions that never came from a trace: rollback restores during reconciliation, spawns, platform rides, accumulated float noise. Any of those can seat the hull exactly flush against a surface (or a hair inside it), and once flush, every sweep into that surface returns a time of impact of zero, and collide-and-slide can only make angular progress. In practice: the character freezes walking over a mountain peak's triangle seam, for no visible reason, until they jump.

The fix is a second engine query that reports every surface within a couple millimeters of the hull, with its outward normal and signed distance. Measured facts, not inferences. At the start of each move, the mover sums up its standoff deficits against all nearby surfaces and applies one capped push (a few millimeters at most) that restores the rest gap. Summing first and pushing once matters: wedged in a V-shaped seam, two opposing pushes sum to the seam's bisector instead of ping-ponging the hull between the walls. Genuinely embedded starts (a moving platform slid into you while you stood still) are handled by a separate stuck-recovery pass that nudges the hull back into open space. Between the two, a move always starts from a legal position, which is the invariant the whole pipeline quietly assumes.

Moving platforms deserve a paragraph, because they're the biggest source of "position writes that never came from a trace." A character riding a platform is re-anchored to it every tick (the platform's pose at that tick times the rider's local offset), and characters standing in a platform's path get de-penetrated along the platform's own direction of travel before they move, using the same contacts query. Making movers coexist with a swept-trace character was its own saga (the repo now has a property-test harness that sweeps eleven families of mover hazards), but the rule that emerged is simple: correctness comes from the sweep, feel comes from playtesting.

The level editor's part: concave-to-convex on export

The other half of the collision performance story is the LEVEL itself. The character is one shape interacting with thousands of map triangles. Even if my character is fast, if the map is slow, I'm slow.

The default Godot collision flow for a static map is "load every CSG / MeshInstance / imported model and let the physics engine collide against the triangle soup directly." That's fine for small maps. For TeamSpec's bigger maps, with hundreds of props and a couple thousand triangles per prop, that approach turned the broadphase into a hot loop. Every projectile, every hitscan, every footstep raycast was checking against the full triangle soup of every nearby prop, even when 99% of those triangles were inside a prop that was itself a simple shape (a box, a cylinder, a slab).

My level editor (which is its own godot-custom export, with all the engine modifications still intact) now decomposes every map prop's collision into a set of convex shapes at export time. For props that are already convex (a cube, a slab, a cylinder), it emits one ConvexPolygonShape3D and that's it — Jolt's broadphase sees one fast-to-test box instead of a 500-triangle ConcavePolygonShape3D, and the test is something like 50x faster per query. For props that are genuinely concave (a horseshoe shape, a model with a hole through it), the editor leans on information it already has: concave geometry in the editor is produced by CSG subtraction, and the mesh split that performs a subtraction naturally yields convex fragments: those fragments get cached at edit time and emitted as the collision set on map compile. (I did try a general-purpose convex decomposition library, CoACD, as an automatic fallback, and removed it: on clean axis-aligned editor geometry it happily merges a doorway's frame into one hull that fills the doorway.) CSG addition operations are really easy because you just merge the meshes but keep the separate collision shapes. In the end, the pieces are individually convex, so Jolt is fast against them, and there are usually around 4-8 pieces per concave prop, so it's still cheap.

There's a potential issue: footstep sounds need to know what surface material you stepped on, and the way that worked was a raycast against the original concave mesh with a triangle index lookup, then the triangle index would map into the per-face material array. If the collision shape gets replaced with a convex hull on export, the triangle index is gone. So the export pipeline gates on a per-prop property: if the prop's collision is convex AND it has a single material, emit the convex hull (fast). If it has multiple materials or the modeler explicitly opted into preserving the original shape, emit the concave version (slow, but the footstep query gets the right material). Most props are single-material, so most of the map benefits, and the rare multi-material prop pays the concave cost only for itself. Since then, footsteps have mostly stopped depending on collision geometry at all: the primary surface lookup now raycasts the visual mesh through a separate Embree BVH on a worker thread and maps the hit texture to a footstep category through a hand-curated surface atlas. The per-triangle physics lookup survives as the fallback path, which is why multi-material props still export the concave version. Either way, none of it runs on the main loop, so it was never going to decide the collision-shape question.

The combined result

The character is 5.4× capsule cost, but capsule cost is so low that 5.4× of nothing is still nothing — about 0.15 ms per body per tick. For a 24-player server, that's 3.6 ms per tick of character collision work, which fits comfortably inside the 16.7 ms budget at 60 Hz. Meanwhile the map-side convex export probably saves more time than the character costs, because raycasts and shape-casts against the static world happen way more often than character moves (every projectile tick, every hitbox query, every footstep).

The shape ALSO behaves the way I want it to behave. You can stand on a ledge whose width equals your radius and your feet don't roll off. Your head hits the ceiling vertically. Your collision against a door frame is the same width whether you're walking forward into it or strafing through it sideways. The shape is consistent in every direction, like a cylinder, and stable under contact, like a convex polygon, and that combination is exactly what the game needed.

Things I'd do differently

I also should have looked up the canonical cylinder-triangle collision paper before writing a single line of SAT code. SAT is for convex polytopes. Cylinders are not convex polytopes. That's not a small detail. The hour I would have spent on that research would have saved two weeks of fighting bugs.

I should have tried the 16-sided hull earlier. I randomly had the Ah-ha moment when I realized that it is possible to use an arbitrary convex shape as a hull. I always thought that if it wasn't a primitive capsule, box, cylinder, or sphere, it wouldn't work because most engines only directly expose those presets for character controllers.

And the lesson that took until July 2026 to fully sink in: interrogate the primitive before you build a system on top of it. I spent over a year assuming the engine's mover was simply how characters move, and patching around its answers, when the real fix was to ask a different kind of question entirely. Trace first and never penetrate, and the whole category of overlap-recovery bugs stops existing. The genre figured this out decades ago; I just took a long time to accept that it wasn't optional. The game's movement has never been more stable than it is right now.