Tutorial 6 · The Player & Profiles
This page explains the player completely — how moving feels, how looks work, and what happens when you get hurt.
The engine makes one big bet: how a game "feels" is just a list of numbers, not code. One controller ships with the engine; Giana Sisters, Jet Set Willy, Bruce Lee, Turrican and Robin of the Wood are all just different numbers fed into it. Prove it to yourself — every slider here edits the live profile:
The profile
Units are pixels per 50Hz tick, so the numbers stay small and comparable. The full set:
| Knob | What it does |
|---|---|
movement |
'platform' (default) or 'topdown' — see below |
inertia |
true: speed builds/bleeds via accel/friction · false: instant full speed, instant stop (purest 8-bit) |
maxSpeed accel friction |
the run — accel/friction only matter with inertia on |
airControl |
0..1 steering strength mid-air; with inertia off it's a gate (0 = jumps drift) |
gravity maxFall |
the fall |
jumpVel |
upward impulse — jump height ≈ jumpVel² / (2 × gravity) |
variableJump minJumpVel |
release early to cap the ascent (Giana/Mario school) |
fixedJump |
the arc commits at takeoff, no steering until landing (Jet Set Willy school) |
coyoteTicks |
grace ticks after leaving a ledge where a jump still counts |
doubleJump doubleJumpScale |
one extra mid-air jump |
wallJump wallSlideSpeed wallJumpVx |
slide down walls, kick off them (Turrican school) |
climbSpeed |
ladders |
deathTicks |
length of the death animation (0 = instant respawn) |
color |
the suit |
jumpKeys actionKeys |
control bindings — see below |
attacks |
the moveset — combat as data, stomp included (see Fighting below) |
energy mercyTicks |
hits per life; a drained point grants mercy frames (0 = classic one-touch) |
The named presets live in playground/profiles.js — copy one and edit, like the Map Editor's Player panel does.
Top-down: the Robin of the Wood school
movement: 'topdown' removes the platformer entirely: no gravity, no jumping, 8-way running on the floor plane with speed-normalised diagonals. SOLID blocks on every side, HAZARD still kills, and only inertia/maxSpeed/accel/friction still mean anything. Tutorial 4 plays a whole world this way.
Jumping and acting
Two profile knobs decide what the buttons mean:
jumpKeys— default['fire', 'up'], both C64 conventions at once. Set['up']for the strict school: up jumps, and fire becomes a free fight button.actionKeys— default the X key, plus fire whenever fire isn't spent on jumping (in top-down, always).
Pressing an action key emits the action event with the player's stance (a.facing, a.moving, a.onGround, a.climbing) — and a moveset (see Fighting below) listens to exactly this event to pick the right move. You can still listen yourself for anything that isn't an attack — opening doors, talking, interacting — the press is yours either way.
The down-stances: crouch and crawl
Every platform-movement body knows what ↓ means now. One press while grounded is a CROUCH — two-thirds height, half speed. A second press is a CRAWL — eight pixels tall, a quarter speed, and that number is the design: a crawling body fits through a one-tile gap. ↑ (or a jump key) rises one stage per press, but only where there's headroom — the crawl that got you under the wall keeps you under it until the ceiling ends, and a jump key can't launch a crouched body (stand first, then leap). Ladders outrank stances: grabbing one stands you up mid-rung. Falling off a ledge keeps your stance; dying resets it.
All of it is profile data, on by default and honestly refusable:
crouch: false // this body doesn't
crouch: { stages: 1 } // crouch only, no crawl
crouch: { crouchH: 12, crawlH: 8, // heights in px
crouchSpeed: 0.5, crawlSpeed: 0.28 } // fractions of maxSpeed
The body reports player.stance (0/1/2) and keeps player.standH beside the live h, and the poses (below) say crouch/crawl — draw the player through paintPlayer() (playground/characters.js) and the whole fifty-three-character roster squashes down convincingly with zero art authored; frame-authored characters may ship real crouch/crawl frames whenever they like (the fallback chain covers them until then).
The stocked powers — and the C key
Instant power-ups act the moment they're touched; the stocked kind wait for you. A collectible with effect: { bomb: 1 } BANKS a Power bomb (up to world.powers.bombCap, three by the classics), and pressing C spends one: every killable enemy in the room dies at once — machines and rooted things survive, kills score as kills. effect: { shield: ticks } is the timed shield (the Power capsule). The player just emits 'power' on the press; the World owns the ledger — world.powers, and the whole run's numbers in world.summary() (kills, collected, rooms seen, deaths, bombs spent, time) — exactly the shape a proper Game Over screen wants. P pauses, everywhere, while we're listing keys.
Getting hurt — and refusing to
Everything deadly routes through player.hurt(cause), and the very first thing it does is emit a cancelable contact event:
player.on('contact', (e) => {
// e.type ('hazard'|'guardian'|'projectile'|'void'|…), e.source, e.x, e.y
if (mercyFrames > 0) return e.cancel(); // shields, invincibility — the hit never lands
sparksAt(e.x, e.y); // or: effects at the exact contact point
});
If nobody cancels, the death sequence starts: the controller freezes for deathTicks (default 45 ≈ 0.9s) while the built-in palette-flashing debris burst plays — replace it by assigning player.deathAnim = (video, camX, camY, t, progress) => …. Then the reset happens: player.onDeath if set (a World uses this to rebuild the room), else respawn(). The Danger Room uses every hook in this paragraph.
The look is yours too
The built-in sprite is a chunky placeholder, and it's replaceable the same way as the death animation:
player.sprite = (video, camX, camY, p) => {
// p.facing, p.vx, p.vy, p.onGround, p.climbing — draw any pose from state
};
Assigning sprite takes over all living-player drawing; nothing about the character's look is hard-coded in the engine. Chan Can Kick's fighters — run cycles, climb arms, the horizontal flying-kick silhouette — are one paintFighter() function handed to this hook.
There's also a ready-made roster: playground/characters.js ships fifty-three characters — Willy to Knight, Robot, Ninja, Wizard, Skeleton, Frog, Cat, Ghost, Ship, Pirate, Viking, Samurai, Zombie, Vampire, Witch, Penguin, Mummy, Jester, Queen — an action pantheon you may find familiar (Blood, Sarge, the Barbarian, The Doc, the Lieutenant, Buster, the Enforcer), a shelf of C64 heroes, Dash, the fully frame-drawn one (more on him below), and Myrtle the Turtle, who carries her own attack frames — each a painter with the shared pose vocabulary (idle / walk / run / skid / turn / jump / fall / climb) plus a poseOf(player) helper that hands the engine's pose through. The Map Editor's Player panel picks from it, and look is independent of feel — any character wears any profile. Adding your own character is adding one entry to the table.
Poses — the body tells you what it's doing
Every tick, the controller names the player's stance in player.pose, so a sprite never has to re-derive physics: idle, walk, run, skid (you let go but momentum still carries — heels down), turn (you're pushing against your own travel — the skid-turn), jump (rising), fall (dropping), climb, crouch or crawl (the down-stances, above). Two clocks ride along: player.poseT counts ticks in the current pose (so a skid animation restarts each time you brake), and player.stridePhase is the foot clock — it advances with ground speed, so footsteps quicken as you accelerate, slow to a stroll, and freeze the moment you stop, even halfway up a ladder.
It's all derived from the physics, so every school keeps its character. An inertia: false profile stops dead and flips instantly — it never walks, skids or turns, which is the whole point of that school — while ice under the same profile brings the skid right back, because ice restores momentum.
Frames as JSON
The engine's sprites.js renders characters straight from data. A frame is rows of hex palette digits (. for transparent), 12×20, drawn facing right and mirrored automatically — the Character Painter's easel format. Give a pose a list of frames and it animates; give it nothing and a fallback chain fills the gap (walk borrows run, fall borrows jump, and everything ultimately lands on the classic stand/step pair — so a two-frame character behaves exactly as it always has):
{
name: 'Dash',
rate: { walk: 8, run: 5 }, // ticks per frame, per pose
frames: {
stand: [ /* one frame: 20 row strings */ ],
step: [ /* the classic other half */ ],
run: [ [/* frame */], [/* frame */], [/* frame */], [/* frame */] ],
skid: [ [/* heels down */] ],
// …walk, turn, jump, fall, climb — author as many as you care to
},
}
Dash in the roster is the worked example — a four-frame sprint the foot clock drums faster as he speeds up, a braced skid, a squared-up turn frame, tucked jump, reaching fall, and a back-view climb that freezes mid-reach when you stop. Clone him onto the editor's easel to see the pixels.
Fighting — the moveset
Combat is data too. Give the profile an attacks table and the engine builds the whole fighting system — no game code:
attacks: {
spin: { // the key names the move AND its frames
stance: 'airborne', // when the fire press picks it
active: 30, cooldown: 250, // the envelope, and five seconds' charge
hit: { shape: 'radius', r: 18, rehit: 10 },
effect: { kill: true }, // everything in the circle, taken out
motion: { spins: 5 },
},
stomp: {
stance: 'stomp', // no button: LANDING on a target IS the attack
effect: { kill: true },
motion: { bounce: 2.6 }, // the rebound off the victim (Mario rules)
},
kick: {
stance: 'moving', // on the move (or airborne): a flying kick
windup: 2, active: 14, recover: 4,
hit: { shape: 'front', reach: 14, oy: 8, oh: 6 },
effect: { kill: true },
motion: { lunge: 2.3, hop: 1.7, committed: true },
},
roar: {
stance: 'still', windup: 6, active: 10, recover: 8, cooldown: 150,
hit: { shape: 'radius', r: 34, rehit: 4 },
effect: { push: 4.5 },
motion: { root: true }, // a roar plants your feet
},
},
Press an action key and the engine picks the first move whose stance matches and whose cooldown is clear — and a move may claim a specific button with keys: ['action'] (the event names which key fired), which is how Myrtle carries fists on Z and a bow on X — a cooling move falls through to the next, so an airborne press spins when the spin is charged and kicks when it isn't. The special stance is 'stomp': no button triggers it — landing on an enemy from above is the attack (the engine checks that your feet were above the victim's crown last tick, so walking into someone is still just a touch), the effect applies, and motion.bounce rebounds you off their back. Every other move runs its envelope (windup → active → recover; hits land only in active), enforces its motion (a committed lunge holds its speed whatever the stick does; root plants you; hop launches a grounded kick), and tests its hit shape — a front rectangle, a radius circle all around you, or body. A World sweeps every enemy through the running move and applies the default effects: push throws them back, stun leaves them reeling (stars over the brow, no thinking, no touching — but still hittable, which is the point), kill removes them. Anything multi-hit gets the same courtesy from game-side hits via world.stagger(mob, { from, stun, push }) — a wounded generator stops pouring while it reels. Machines — pistons, crushers, vents, turrets — are anchored and beneath argument.
What a hit means beyond that is yours, as ever, through events on the player: attack-start, attack-hit (the one to listen to), attack-end, and attack-denied when a cooldown refuses you — that last one is your HUD buzz, and moves.cooldownFrac('spin') feeds a charge bar. Games with their own enemy lists call player.moves.strike(enemy) themselves; games that want full control declare stance: 'manual' and call moves.start(key) — Storm the Gates throws its lances that way.
Four ready-made fighting presets ship in playground/profiles.js — myrtle (kick / shell spin / roar / stomp), brawler (the Chan school: punch, flying kick, stomp), juggernaut (shoulder charge, room-clearing slam, a heavy stomp) and shadow (wall-jump agility, a lightning strike, an airborne whirl) — pick any of them in the editor and the fire key is armed.
While a move runs, its key is the pose — so a character JSON authors frames.spin, frames.kick, frames.roar and they play by name, falling back down the standard chains when unauthored. Myrtle the Turtle in the roster is the worked example: pick her profile in the editor and she fights out of the box — she tucks into her shell for a five-revolution spin, lunges into a flying kick, and roars the whole room backwards.
Feelings
Players have emotions now, and they cost you nothing: player.emote(kind, ticks) gives the character a feeling, drawn above the sprite so it works with every look — built-in, roster, or painted. cheer is the big one: orbiting celebration stars, and on the platform plane the player jumps up and down by themselves until it wears off. spark is a collect's little twinkle, love a beating heart (power-ups earn it), shock a flashing ! when something opens, and dizzy the orbiting stars of a hit survived.
You rarely need to call it yourself — the World supplies the defaults: collecting sparks, drinks earn hearts, gates and exits shock, winning cheers, and surviving a hit (armour, mercy frames) leaves you dizzy automatically. Set player.emotion = null if a moment calls for stoicism.
The full event list
'contact' (cancelable) · 'die' · 'die-tick' · 'death-finished' · 'respawn' · 'jump' · 'double-jump' · 'wall-jump' · 'land' (with impact speed) · 'bounce' (springboards) · 'climb-start' / 'climb-end' · 'action'. Subscribe with player.on(name, fn) — effects, sound, and score belong in listeners, not in the controller.
What you've learned
One controller, many games: the profile is the character. Buttons are data (jumpKeys/actionKeys), moves are events (action), and even dying is a negotiation (contact + cancel()).