2D worlds
Arcwright 2D scenes are Y-up: x runs right, y runs up, in world units. Everything on this page is plain scene data, so a person in the inspector, a script and the AI builder all change the same fields. Each section lists the scene fields, the script calls and the agent tools.
Draw order: layers, feet and attachments
Sprites draw by layer (higher in front). Turn on y_sort for anything you can walk behind (people, trees, lamp posts, crates). On one layer, a y-sorted sprite whose feet are higher up the screen draws first, so the hero passes behind a tree when standing above its base and in front of it when below. The feet are the position (with the usual pivot (0.5, 0, 0)) plus sort_offset units. Ground tiles and plain sprites on the same layer always draw before the sorted ones. Equal feet are ordered by entity id, so the order never flickers.
sprite { image "2D/wellspring/props.png" frame_width 238 frame_height 337 frame 6 layer 20 y_sort true sort_offset 4 }
Attachments such as a held lantern, a weapon or a shadow set sort_with "Hero". They sort exactly with that object, just in front of it, or just behind it with a negative sort_offset. A carried item is drawn this way while it is carried.
Use separate layers for bands that never mix (ground 0, actors and props 20, roofs 30, foreground leaves 40). A bridge you can walk both over and under needs two layers (the deck above, the path under), not one sort key.
Scripts: self.layer = 20, self.ySort = true, self.sortOffset = 6, lantern.sortWith = hero (or null). These replace the old Wellspring trick of writing a layer from the hero's y each tick.
Roofs and canopies that fade when you walk under
Give each roof, canopy or upper-floor sprite (or a whole roof tilemap) roof "inn". Then add one object with a roof_2d area:
entity Inn_Roof_Area { transform { position (640, 420, 0) }
roof_2d { group "inn" size (300, 220, 0) hidden_opacity 0.12 fade 0.25 margin 12 } }
While a player's feet are inside the box, every member of the group fades to hidden_opacity over fade seconds. The roof fades back only after the player leaves the box grown by margin, so standing on the edge never flickers. Collision is separate. The house walls stay solid bodies and the roof sprite has no body. The area's object gets onMessage("roof", { inside }). Scripts read game.roof("inn") (0 shown, 1 revealed).
Top-down movement, pushing and carrying
body_2d { kind "topdown" } walks 8 ways from the arrows/WASD or the stick. Shift runs. A diagonal is exactly as fast as a straight line (measured within 1%). run_speed, sprint_speed, acceleration and deceleration tune it the same way as the platformer. A scene with a top-down body has no gravity for anything in it.
- Push:
kind "dynamic"bodies are shoved along by walking into them, in all four directions. They stop at walls and slide to rest withfriction. The pushed body getsonMessage("pushed", { by }). - Carry: give a dynamic body
carry true. The use key (E) picks up the nearest one in reach, preferring the one in front, and carries it overhead. E again puts it down in front, or beside or behind if the front is blocked. It is never put inside a wall. Shift+E throws the carried item at 600 units/s in the facing direction, using the same safe release check. Scripts can callself.pickUp()/self.pickUp(pot),self.putDown(),self.throw(700)(units/s, the way the hero faces) andself.carrying. Messages:picked_up,carried,put_down,threw,thrown. self.bodyaddsdir(the 8-way facing),carrying,carriedByandonSlope.- With
animate truea top-down body playswalk_up/walk_down/walk_side(mirrored for left), thenrun_*,walk,run, andidle_up/idle_down/idle_side/idlewhen standing. It uses whichever clips the sprite has. While carrying, it first triescarry_walk_*/carry_walkwhen moving,carry_idle_*/carry_idlewhen still, thencarry_*,carry, andlift. Missing carry clips fall back to normal movement. The Wellspring hero sheet already uses the movement names.
Bots: autoplay (and TopDownBot2D) plays top-down levels through the real keys. It maps where the player's own box fits, plans an 8-way route with no corner cutting, and walks it, re-planning when crates move or doors open. Its report says "TopDownBot2D: real top-down controls". Goals use the same tags as the platform bot: the key first while a gate stands, then the goal.
Camera rooms
entity Shop_Room { transform { position (2400, 450, 0) }
camera_zone_2d { size (1600, 900, 0) priority 0 blend 0.4 view_height 0 look_ahead -1 } }
While the camera's follow target is inside a room, the view stays inside the room's box. Its edges are hard limits. The room can set its own view_height, deadzone and look_ahead (0 / (0,0,0) / -1 keep the camera's). Where rooms overlap, the highest priority wins. With equal priority, the current room stays. Outside every room, the current room stays. Changing room slides the view over blend seconds from the old room's limit to the new one's, and never shows outside the two rooms. Without rooms, camera_2d's own bounds_min/bounds_max work as before.
Scripts: game.cameraRoom (the room entity or null), room.roomEnabled = false to close a room (a locked area), and onMessage("camera_room", { room, from }) on both the room and the followed object.
Slopes, outlines and collision queries
Tiles can have a shape instead of the full square. In the tileset, "slope": "up" (rising to the right), "down", "up_low", "up_high", "down_low", "down_high" (two-tile gentle slopes) or "half". A custom convex shape is "shape": [[0,0],[1,0],[1,0.4]] in cell shares from the bottom-left corner, y up. Platform movers walk up slopes up to max_slope degrees (default 50) and stay on the ground walking down them. Steeper shapes are walls. Top-down bodies slide along slanted edges.
Solid bodies can use a convex outline instead of a box: body_2d { kind "solid" shape ["-80,-50", "70,-60", "90,40", "-20,80"] } (points in units from the position; the hull is used; it turns with rotation z). Use these for rocks, tree trunks, table footprints and slanted walls. The outline is the contact footprint, not the whole picture: a table blocks only its legs' footprint, and you can walk behind its top.
kind "projectile" flies straight at its velocity (self.velocity = [vx, vy]) and is swept every tick, so it never passes through a wall, an outline or an enemy, however fast. On contact it gets onMessage("hit", { entity, x, y, nx, ny }) (entity -1 for a tile), and what it hit gets onMessage("struck", { by }). Then it stops, or it rebounds with bounce. It never hits the body it started inside, which is its shooter.
Queries: world.ray2d([x, y], [x2, y2], ignore) returns { entity, point, normal, distance } or null. world.overlap2d([x, y], [w, h]) returns the bodies there. world.sweep2d(entity, [dx, dy]) tells how far a body could move before touching something ({ t, point, normal, entity }), without moving it.
The collision overlay (agent tool collision_overlay_2d, or Scene2DBuilder.showCollision) draws every shape: solid tiles and slopes in green, one-way tiles in cyan, solid bodies and outlines in yellow, movers in white, sensors in magenta, camera rooms in blue, roof areas in orange, and each y-sorted sprite's feet in red.
Game-wide state and save slots
game.state is what the whole game remembers across scenes: coins, inventory, quest stages, crops, keys.
game.state.add('coins', 5); // returns the new total
game.state.set('quest', { stage: 'mine', lantern: true });
if (game.state.flag('met_elder')) { ... }
var crops = game.state.get('crops', []); // a default when missing
Values are plain data: numbers, strings, booleans, lists and objects. It carries over when a script calls game.loadScene("Mine"), and it is cleared by game.newGame(). Save it with game.saveSlot(1, "Day 3"). That writes Saves/game.slot-1.json atomically, with the scene and where the player stands. Load it with game.loadSlot(1). A save from another scene switches to that scene and places the player on arrival. game.continueGame() loads the newest slot. game.slots() lists them as { slot, status, label, scene, savedAt, playSeconds }. A slot written by a newer engine (status "newer") or a damaged file ("damaged") is refused and never overwritten. Delete it on purpose with game.deleteSlot(n). game.profile.get/set is permanent across every slot (settings, unlocked modes).
Keep the layers apart: game.state is the whole run, the scene's own objects are the scene, and game.save(slot) / game.load(slot) remain the per-scene checkpoints of the exact world. Bots, replays and proofs keep their game state in memory and never touch a player's Saves folder.
Agent tools
tilemap_paint_region: paints a bounded, undoable rectangle of one tile character (.erases). Autotile edges andwall16/blob47corners re-pick from their neighbours.world_2d_check: the 2D world report for a scene or the running game. It lists y-sorted sprites in draw order with their feet, sort inversions between overlapping sprites, roof groups and their state, camera rooms (overlaps, rooms smaller than the view), bodies with bad outlines, and the game state store.collision_overlay_2d: turns the collision overlay on or off in the editor and in Play.game_state_2d: reads the running game's state and slots, and sets values for testing (set,new_game).
The terrain rules wall16 and blob47 are tileset terrains with "rule" and exactly 16 or 47 "frames". The blob rule joins a corner only when both of its sides join.
Repeatable play checks
play_test follows game.loadScene, lists scenes_visited in its receipt, and starts with an empty game.state. Pass state: {coins: 5} to seed only that run. An editor Play session keeps its own state.
inputs uses {from, to, move, keys, fire, look, sprint, crouch} in both play_test and feel_2d_report. Unknown input fields are errors. While a modal game UI is open, Return or Space confirms its focused widget and arrow keys move focus. Gameplay waits until the UI closes.
Pass frames: [0.5, 1.5, 3] to play_test to capture these moments in the same run, followed by the final frame. The receipt records each requested time, actual tick time, scene and UI screen. At most twelve times are accepted. Times after a win or failure are not captured; only completed captures appear in the receipt.
A small particles_2d emitter stays at its world position when follow_camera is omitted. An area covering both view dimensions follows the camera. An explicit true or false always wins and survives saving.
Scripts can use game.player for a top-down hero or an object tagged player, and use self.tag(name) / self.untag(name) to change tags during play. self.health and self.maxHealth read action_2d health.
backdrop_2d { bloom_limit 0.15 } caps that layer's material bloom gain. Use zero to remove its halo; this does not dim its painted or emissive color. The default 16 preserves existing scenes. This is a gain limit on emissive-map bloom; it does not suppress a scripted dissolve edge. Other layers keep their own bloom.