A base environment for prototyping 2D platformer mechanics. The arena is an ASCII tilemap with solid floors and walls, one-way platforms, and named spawn points; the entities living in it get their bodies from one shared module, so a new test entity describes only its behaviour — never its collision.
The demo drops a player, two patrolling walkers, a bouncing ball, and a floating ghost into the same arena. All four are the same mixin with different config.
Reuse
Four framework-free ES modules in src/:
collidable.js — the composable part. makeCollidable(entity, opts) mixes an
AABB, velocity, gravity, and swept tile collision into any object and returns it:
import { makeCollidable } from './collidable.js';
const slime = makeCollidable({ hp: 3 }, {
x: 40, y: 40, w: 12, h: 12,
gravity: 1500, bounce: 0, useOneWay: true,
onCollide: (e, hit) => { if (hit.axis === 'x') e.hp--; },
});
slime.moveAndCollide(arena, dt); // once per frame — that's the whole contract
Per frame it maintains the flags an entity would otherwise re-derive:
grounded, wasGrounded, ceiling, wall (-1/1), groundedTimer (free coyote
time), and airTime. Also exported: aabbOverlap(a, b) for entity-vs-entity
checks, groundAhead(e, world, dir) for ledge detection, and stepBody if you’d
rather call it directly than through the mixin.
Opting out is config, not a separate path: gravity: 0 floats, solid: false
skips tiles entirely, bounce: 0.7 makes it a ball, useOneWay: false ignores
platforms. collision.dropThrough = true for one frame falls through the one-way
underfoot.
arena.js — createArena({ map, tileSize }) parses an ASCII map
(# solid, = one-way, lowercase letters = spawn markers) into a tile grid with
solidAt, oneWayAt, tileAtPoint, setTile, spawn(letter), and canvas
rendering (draw, drawBackground, drawTiles). Out-of-bounds reads as solid by
default, so the arena is a closed box.
entities.js — worked examples: createPlayer (acceleration, coyote time,
jump buffering, variable jump height, drop-through), createWalker (patrols,
turns at walls and ledges), createBouncer, createGhost, plus drawDebug.
Copy one as the starting shape for a new mechanic.
input.js — createKeyboard() with edge-triggered presses; consume()
returns one frame’s snapshot and clears them, which is what a fixed-timestep loop
needs.
test/collision.test.mjs is a headless regression check for the collision
module (resting flush, no tunneling at 20000 px/s, one-way land/rise/drop-through,
jump height and jump-cut, ledge turning): node test/collision.test.mjs. Its
coordinates are tied to DEFAULT_MAP — re-derive them if you edit the map.
The world argument is duck-typed — anything with { tileSize, solidAt(col, row), oneWayAt(col, row) } works — so entities aren’t bound to this arena.
Gotchas
- Collision is axis-separated (X, then Y) and substepped to half a tile, so nothing tunnels through one-tile-thick geometry no matter how fast it moves. The cost is the classic corner behaviour: a body moving diagonally into an inside corner resolves X first.
wall,grounded, andceilingdescribe this frame’s collisions. A body resting against a wall withvx === 0reportswall: 0— it isn’t colliding any more. Wall-slide logic should readwallwhile still pushing into it.- The mixin writes its fields directly onto the host object (
COLLIDABLE_FIELDSlists them). Don’t name an entity propertyx,vy,wall, … - The demo runs a fixed 1/120 s timestep with an accumulator. Feeding raw frame
deltas into
moveAndCollideworks, but tuning drifts between refresh rates. demo/bundles its own copies of the modules (self-contained by contract). If you touchsrc/, re-copy:cp src/*.js demo/.
Port notes for DragonRuby: collidable.js and arena.js are pure state math with
no DOM — stepBody translates directly into a tick method, and the tile grid maps
onto a flat array the same way. Only the draw functions and input.js are
web-specific.
