Spielwerk 26: Paketrutsche

2026-11-01
Spielwerk 26: Paketrutsche - Teaser

A parcel depot: parcels drop out of a hatch, and you place and turn three wooden boards so they slide through a funnel into the cart. Every fifth parcel is an express that shoots out of a tube faster than a board is thick per frame. This step starts the toolbox's physics suite with the separating axis test for convex shapes, sat_overlap, sat_sweep and sat_move, and a switch shows why the sweep matters.

Play it · Source: collection-spielwerk/26-paketrutsche · Toolbox 5.7.0 or later

The game

A shift is 120 seconds and a parcel drops every 2.5 seconds: a crate, a triangle, a hexagon or a long flat "Brief". The score is how many land in the cart. The boards start where about half get there, so doing nothing already delivers, and a missed parcel just falls out of the bottom. Parcels never turn, only boards do.

New in the toolbox: SAT for convex polygons

The problem. Step 11 and step 17 used aabb_*: boxes that stay level. A board you can turn isn't a level box, and neither is a funnel's slope. Its bounding box covers a big empty triangle, and a parcel would stop in mid-air above it.

The idea. Two convex shapes are apart exactly when a line fits between them, and that line is always parallel to one of their edges. So look at their shadows on the normal of every edge. If any two shadows don't overlap, the shapes don't either. If they all overlap, the axis with the least overlap is the shortest way out. That's sat_overlap. A moving shape's shadow slides along each axis at its projected speed, so on each axis there is a time it starts and a time it stops overlapping. The shapes touch from the latest start to the earliest stop. That's sat_sweep, the same slab test aabb_sweep does on two axes, and exact for any speed.

// The smallest push that moves a out of b (normal from b towards a), or null.
sat_overlap(a: PolygonI, b: PolygonI): Contact | null
// When mover, moving by delta, first touches a still wall: time 0..1 and the normal of the face they meet on.
sat_sweep(mover: PolygonI, delta: Vector2I, wall: PolygonI): Hit | null
// Moves and slides along what it hits, at most 3 passes; returns how far it went.
sat_move(mover: PolygonI, delta: Vector2I, walls: readonly PolygonI[]): { offset: Vector2I; hits: Hit[] }

They reuse Hit from aabb and Contact from bounce. They are functions, not a world: the game owns its parcels, steps them and decides what a hit means. Here a hit takes the speed into the surface away, leaves a little bounce and a little friction, and lets the rest slide:

velocity.y += GRAVITY * STEP_SECONDS;
const delta = { x: velocity.x * STEP_SECONDS, y: velocity.y * STEP_SECONDS };
if (this.is_swept) {
    const moved = sat_move(parcel.polygon, delta, walls);
    parcel.x += moved.offset.x;
    parcel.y += moved.offset.y;
    for (const hit of moved.hits) {
        this.land(parcel, hit.normal);
    }
} else {
    // the naive way: move, then push out of whatever it sits in
    parcel.x += delta.x;
    parcel.y += delta.y;
    for (const wall of walls) {
        const contact = sat_overlap(parcel.polygon, wall);
        if (contact !== null) {
            parcel.x += contact.normal.x * contact.depth;
            parcel.y += contact.normal.y * contact.depth;
            this.land(parcel, contact.normal);
        }
    }
}

What the switch shows. An express parcel moves 45 units a step, and a board is 10 thick. Turn "Sweep" off and the parcel is in front of the board one step and behind it the next. The overlap test never sees the two touch, or sees the parcel more than halfway through and pushes it out the far side. The game marks it "durch!". With the sweep on, the same parcel stops at the board's face and slides down it. A slow parcel behaves the same either way; only speed tells them apart.

What using it turned up. sat_move keeps the mover 0.01 units off what it hit (SAT_SKIN), so rounding never leaves it inside. And a parcel that can't turn will balance for ever with its flat bottom on a board's corner: the contact's normal is then the parcel's own face, straight up. Turning would tip it off. The game stands in for that: a parcel that has stood still for a moment and has its middle beyond every point holding it up gets a push to that side.

A test worth copying. No speed may get through a board one unit thick:

it("never ends inside a thin board, however fast", () => {
    const thin = board({ x: -1e6, y: 120 }, { x: 1e6, y: 120 }, 1);
    for (const speed of [200, 2000, 20000, 200000]) {
        const square = box(-5, -5, 10, 10);
        const result = sat_move(square, { x: speed / 10, y: speed }, [thin]);
        expect(sat_overlap(moved(square, result.offset), thin)).toBeNull();
        expect(result.offset.y).toBeLessThan(115);
    }
});

The old SweepingSatWorld and PhysicsBody are gone. Despite the name, the world swept bounding boxes and stopped a body dead where it should slide. A physics world that steps bodies itself will come later, as its own step. Path: src/physics/sweeping_sat/Sat.ts. Polygon got translated() and rotated() for the boards and parcels.

From earlier steps

The scaffold, start screen, intro and scaling are explained once in Spielwerk 00: The workbench.

Embed it

<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-paketrutsche"></script>
<tce-paketrutsche init-seed="4711"></tce-paketrutsche>
Attribute Values Default
init-seed whole number; the first shift, every next shift is seed + 1 random
init-intro true / false: play the GO logo intro at the first connection true

Next

Previous: Spielwerk 25: Wochenmarkt · Next: Spielwerk 27: Sandkasten · All steps · Where the series is heading: Civilization

Comments

captcha