workshop

← all creations

Platform Arena

mechanics · created 2026-08-21

A tile-based 2D platforming test bed — solid floors and walls, one-way platforms, and a collidable mixin every new entity composes with.

platformerphysicsmovementcanvasarchitecture

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.jscreateArena({ 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.jscreateKeyboard() 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

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.