2D animation and action feel
How a 2D character's frames, states, hits and impact effects work. Everything here is plain scene text, so a person, a script or the AI builder writes it the same way (apply_scene_text, the Inspector's "As text" view, or a kit). Positions are world units on X (right) and Y (up); the 2D tick runs at 120 Hz.
Frame timing and frame events: anim_2d
sprite clips choose frame ranges ("attack=10-14@12 once"). anim_2d adds the rest:
anim_2d {
frame_times ["attack=0.04 0.04 0.10 0.06 0.08", "run=0.065"]
events ["run@1=footstep", "run@4=footstep", "attack@2=hit", "attack@1=sound:swing", "attack@2=spawn:slash"]
boxes ["attack@2-3=hit 50 40 60 40", "*=hurt 0 40 40 80"]
}
frame_times: seconds per frame (one number for every frame; fewer numbers than frames repeat the last). Hold a key pose by giving it a longer time. Artwork drawn at 12 or 24 fps plays correctly over 120 Hz gameplay.events:clip@frame=name, frames counted from 0 inside the clip. Each event fires once every time the playhead crosses its frame, including frames skipped by a long step, every loop, and the first frame when a clip starts. Nothing fires while the actor is frozen (hit-stop) or after a state is interrupted.sound:NAMEplays a sound,spawn:NAMEbursts an effect (see below). Scripts get every event asonMessage(self, 'frame', { event, value, clip, frame, loop }).boxes:clip@first-last=kind x y w h. The centrex, yand sizew, hare world units from the object's position, for art facing right.flip_xmirrors them.clip=...covers every frame of a clip,*=...every clip. Kindshitandhurtare used by the hit system; other kinds are kept for scripts.
State machine
anim_2d {
states ["idle", "run", "jump", "fall", "attack priority=10 cancel=4 then=idle", "hurt priority=20 then=idle"]
transitions ["idle>run if speed>20", "run>idle if speed<=20", "idle|run>jump if !grounded vy>0",
"idle|run|jump>fall if !grounded vy<=0", "jump|fall>idle if grounded", "*>attack on attack", "*>hurt on hurt"]
}
- A state plays its
clip(default: its own name;walk_{dir}fills in the text paramdir, so one state coverswalk_down,walk_sideandwalk_up). - Transitions are checked in order; the first that holds and is allowed wins. Conditions:
on TRIGGER(a buffered press),if param>value(also<,>=,<=,=,!=,param,!param),done(the one-shot ended), andafter SECONDS. - A one-shot state is locked. Only a higher
priority, itscancel=first-lastframes, or its end can interrupt it. Running and jumping can't cut an attack short; a hurt state (priority 20) can. - Triggers wait
bufferseconds (default 0.15) of the actor's own time, so a press during hit-stop still lands. - Built-in params, from
body_2d:speed,vx,vy,grounded,climbing,wall,facing,on_ladder. - When the object has
body_2d { animate true }and states, the state machine picks the clips and the body only turns it to face its way.
Scripts: self.anim.fire('attack'), self.anim.set('dir', 'side'), self.anim.state, .clip, .frame, .done, .frozen; self.play('attack') forces a state by name. State changes arrive as onMessage(self, 'state', { state, from }).
One accepted hit: action_2d
action_2d { team "player" damage 1 hit_stop 0.05 shake 3 shake_time 0.12 flash 0.08 knockback (320, 240, 0) burst "spark" sound "hit" }
A hit comes from a hit box overlapping another object's hurt box (or its body_2d box when it has none), or from a script's self.hit(target, options). Each attack (one play of an attacking clip, one loop pass, or the key a script gives) lands on each target once. Damage is applied once, and that single accepted event drives everything else:
- hit-stop on both the attacker and the target (
hit_stop, presets 0.033 / 0.05 / 0.083 s);world_stoppauses everything for exceptional impacts; - camera shake in screen pixels of a 720-line screen, or virtual pixels in pixel art (1 to 4 px over 0.08 to 0.18 s);
- the target's flash (
flashseconds inflash_color), knockback, aburstat the contact point, andsound; onMessage 'hurt' { damage, by, seq, x, y, dir, health }to the target,'hit_landed' { target, seq, damage }to the attacker, and'defeated'once itshealthreaches 0 (health 0 means scripts keep their own).
squash 0.15 adds landing and take-off squash and stretch with a dust puff. It only changes the drawn scale; the body's collision box never changes.
Clocks: simulation (the tick), each actor's animation clock (it stops during hit-stop), and presentation (shake, flash and effects keep playing through hit-stop). Input is still read and buffered while an actor is frozen.
Scripts and settings:
self.hit(other, { damage, key, hit_stop, shake, flash, knockback_x, knockback_y })self.freeze(s),self.flashHit(s, color),self.trail(s)(dash afterimages),self.squash(amount, s),self.burst('dust')game.burst(kind, [x, y]),game.stopWorld(s),game.shake(px, s)(2D: pixels)game.feel({ shake: 0..2, flash: 0..1, hit_stop: 0..2, effects: true|false })for reduced shake or flash and an effects-off comparison. Turning effects off never changes hits, damage or positions. It returns the feel report (hits, duplicates rejected, frame events, live/dropped effects).
Bursts (spark, dust, slash, poof, sparkle, or an image in Assets) come from a pool of at most 192 effect sprites; past that a burst is dropped and counted, never allocated.
The flash is currently drawn by over-brightening the sprite's tint (TintFlash2D). The 2D lighting lane's silhouette-flash material replaces it through the Flash2DRenderer interface.
Cut-out rigs: rig_2d
rig_2d {
image "2D/rigs/knight_parts.png" pixels_per_unit 2 layer 10 clip "idle"
bones ["root - 0 0 0", "hip root 0 46 0", "torso hip 0 0 90 part=0,0,60,80 pivot=0.5,0.1 z=2",
"thigh_l hip -8 0 -90 len=22 part=60,0,20,46 pivot=0.5,0.9", "shin_l thigh_l 22 0 0 len=22 part=80,0,18,46 pivot=0.5,0.9",
"foot_l root -8 2 0"]
attachments ["hand_r: sword=100,0,20,80 axe=120,0,24,80"]
poses ["stand: torso=0", "wind: torso=-12 arm_r=-80 hand_r.attach=axe", "strike: torso=10 arm_r=60 arm_r.z=6"]
clips ["idle=stand@0 stand@1", "attack=wind@0 strike@0.08 stand@0.3 once"]
ik ["leg_l: thigh_l shin_l target=foot_l bend=1"]
}
- Bones are listed parents first:
name parent x y angle(rest pose relative to the parent, degrees), thenpart=x,y,w,h(its rectangle of the image, pixels),pivot=(where the bone sits on the part, as shares),z=(draw order) andlen=(bone length, for IK; a bone's length runs along its own +x). poseskey angles (degrees from rest) and.x .y .s .z .attach.clipsplace poses at seconds, eased between. Clip changes cross-blend overblendseconds.iktwo-bone chains reach a target bone (scripts can move the target:self.rig.target('leg_l', [x, y]);nulllets go).- With
anim_2d, the rig is driven like a sheet: a rig clip's keys are its frames, soevents,boxes,statesandframe_timesall apply.
Scripts: self.rig.play(clip), self.rig.attach(bone, picture), self.rig.target(chain, point), self.rig.bone(name) / self.rig.tip(name) give world points (a sword tip for a trail, a hand for a grab).
Lifelike characters: motion_2d
motion_2d animates a rig_2d character from numbers instead of drawn frames. Add it next to the rig; it reads the body (speed, air, landings, slopes), the anim_2d state and script calls, and poses the bones on top of the rig's own clips every tick:
motion_2d { body "biped" weight 1.15 attack "slash" attack_weight 1.15 squash 0.9 blink 4 death "crumple" }
- Locomotion: breathing and a slow weight shift at rest; walk and run blended by speed (the stride follows the distance travelled, so feet don't slide); knees bend when the rig has shins; body bob, lean into speed and slopes.
- Air: take-off crouch-and-stretch, rise, apex float and fall poses picked from the vertical speed, a landing squash and knee bend sized by the impact, with a spring bounce. Turn-arounds pinch and skid.
- Moves (
attack, or names inactions):slash,thrust,smash,throw,lunge,wave,dash. Each has four frames, wind-up, strike, follow-through and recovery, timed byweightxattack_weight(telegraphadds warning time to the wind-up). An anim_2d state of the same name gets those four frames as its clip, soboxes ["attack@1-2=hit ..."]hit on the strike and follow-through. Fast swings draw smear copies of the weapon arm and atipsocket draws a weapon trail. - Hits: an accepted hit flinches it away from the hitter (springs: a sharp hit frame, then a wobble back); a hit during a wind-up cancels the move. When
action_2dhealth reaches 0 it plays itsdeath:crumple(knees give, it folds and drops),fall(topples),scatter(falls apart into its bones),pop(bursts). - Faces: blinks, eyes that look at the player (or
self.motion.lookAt), talking mouth shapes from the text, and emotions (neutral,happy,angry,scared,hurt) with matching body language. Pictures come from the rig's attachments on bones namedeyes,pupils,browsandmouth. - Secondary motion: hair, capes, scarves, plumes, ears, chains, tails and lanterns hang on springs.
- No pops: moves, interrupts, aims and deaths cross-blend; the tests hold every bone under 10 degrees per 120 Hz tick outside a weapon swing (the most is a knee buckling on a full-height landing).
Scripts: self.motion.play('attack'), .emotion('angry', seconds), .say('Evening, traveller!'), .talk(false), .aim(degrees | null) (the weapon arm follows; the body and head share it), .lookAt(point | entity | null), .die(), .hurt(dir), and .move, .dead, .talking. self.play(name) starts a move on a motion rig. Messages: onMessage(self, 'motion', { event }) with telegraph, strike, contact, land, step, dead. Throw the spear or land the slam on strike / contact so the gameplay matches the pose.
From Paper Forge to a live character
A Paper Forge kind: "rig" recipe becomes a live rig with motion_2d_rig {entity, preset} (or TTRHeadless --pf-rig name.paperforge.json 2 1). Parts become bones at their joints; sockets ride the part they sit on; spring words in a part's name make it hang. Faces swap alternative parts:
pf.part("eyes", {pivot: [3, 54], parent: "head", z: 3.6}) // open eyes (shown by default)
pf.part("eyes_blink", {alt: "eyes"}) // same joint, never drawn into the baked frames
pf.part("mouth_a", {alt: "mouth"}) // talking shapes: a, e, o, m; moods by emotion name
Alternatives named <base>_<name> become the base bone's picture <name>: eyes blink, happy, angry, glare (telegraph), scared, hurt, dead; mouths a, e, o, m and the emotions; brows by emotion. A pupils bone under eyes looks around and hides when the lids close. Legs split into leg_* and shin_* bend at the knee.
Tuning in plain words
motion_2d_tune {entity, changes: ["heavier attack", "more squash"]} applies reviewed multipliers and reports the numbers it changed: heavier/snappier attack, bigger/smaller swing, more/less squash, bounce, lean, breath, smear, flinch, dash, stride, blink more/less, heavier/lighter, an emotion, telegraph attacks, and death styles.
Corner correction
body_2d { corner_correction 8 } (player, default 8 units): a jump that clips the corner of a ceiling by up to that much slides past it instead of stopping. Coyote time, jump buffering and variable jump height are the existing coyote_time, jump_buffer and jump_cut_gravity fields.
What the tests measure (Tests/TTRGameplayTests/Animation2DTests.swift, Feel2DTests.swift, Rig2DTests.swift)
- 10,000+ event crossings through loops, skips, interrupts and frozen steps: 0 lost, 0 duplicated.
- 1,000+ hits from boxes and scripts at once: 0 double damage; health equals the start minus the accepted damage.
- Hit-stop holds both sides for its duration within 1 tick; a press during it is buffered.
- 100 attack presses during a fight: response at most 2 presented 60 Hz frames in at least 95.
- Effects on and off give identical hits and positions.
- 100 squash, trail and interrupt cycles: 0 collision-size changes and 0 leaked effects.
- 100 rigs of 32 bones posed within the CPU budget (p95 1.5 ms per tick).
- motion_2d (Motion2DTests.swift): a 14-second performance (walk, run, stop, turn, jump, landing, attacks, an interrupt, hits, a cancelled wind-up, a dash, aiming, death) with no bone turning more than 10 degrees or moving more than 6 units in one 120 Hz tick outside the weapon's swing; walk/run swing grows with speed; heavier attacks take longer while the strike stays fast; blinks, talking shapes and telegraph glare; scattered bones settle; a Paper Forge manifest converts with its face pictures, springs and sockets; in a session the attack state hits once on its strike frames and a defeat plays the death.
To measure a side-on fight, call feel_2d_report with seconds and bot: {attack_key: "J", range: 100, cadence: 0.5}. This input probe approaches the nearest opposing action_2d body and presses the actual attack key. It jumps at walls, ledges and elevated targets; it does not plan routes or force hits. Set the key/range/cadence to the game's attack. Do not combine bot with inputs. The report includes accepted damage taken/dealt, defeats, death time when the action health reaches zero, and damage taken per minute over the run's actual duration. These numbers describe the chosen run; they are not a claim about human difficulty.