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:NAME plays a sound, spawn:NAME bursts an effect (see below). Scripts get every event as onMessage(self, 'frame', { event, value, clip, frame, loop }).
  • boxes: clip@first-last=kind x y w h. The centre x, y and size w, h are world units from the object's position, for art facing right. flip_x mirrors them. clip=... covers every frame of a clip, *=... every clip. Kinds hit and hurt are 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 param dir, so one state covers walk_down, walk_side and walk_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), and after SECONDS.
  • A one-shot state is locked. Only a higher priority, its cancel=first-last frames, or its end can interrupt it. Running and jumping can't cut an attack short; a hurt state (priority 20) can.
  • Triggers wait buffer seconds (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_stop pauses 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 (flash seconds in flash_color), knockback, a burst at the contact point, and sound;
  • onMessage 'hurt' { damage, by, seq, x, y, dir, health } to the target, 'hit_landed' { target, seq, damage } to the attacker, and 'defeated' once its health reaches 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), then part=x,y,w,h (its rectangle of the image, pixels), pivot= (where the bone sits on the part, as shares), z= (draw order) and len= (bone length, for IK; a bone's length runs along its own +x).
  • poses key angles (degrees from rest) and .x .y .s .z .attach. clips place poses at seconds, eased between. Clip changes cross-blend over blend seconds.
  • ik two-bone chains reach a target bone (scripts can move the target: self.rig.target('leg_l', [x, y]); null lets go).
  • With anim_2d, the rig is driven like a sheet: a rig clip's keys are its frames, so events, boxes, states and frame_times all 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 in actions): slash, thrust, smash, throw, lunge, wave, dash. Each has four frames, wind-up, strike, follow-through and recovery, timed by weight x attack_weight (telegraph adds warning time to the wind-up). An anim_2d state of the same name gets those four frames as its clip, so boxes ["attack@1-2=hit ..."] hit on the strike and follow-through. Fast swings draw smear copies of the weapon arm and a tip socket 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_2d health reaches 0 it plays its death: 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 named eyes, pupils, brows and mouth.
  • 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.

Arcwright Beta 2 (0.4.0) · generated from the engine source (25e375c86)Something wrong or missing? Tell us
Download Arcwright free