Skip to content
Felucca Online

Decision log

Why it is built this way

Every decision that shapes the game, newest first: the engine, the look, the rules, the tools. A decision is never deleted; when one replaces another, the old one is struck through and points to its successor.

  1. #104
    A living world: dressing every location, and the 1060-prop order (user directives: river banks looked torn, like a bug; the houses are alive but the world is empty, fill every territory with props, order at least 1000 new assets from haystacks and rye sprouts to skulls and bones, think every location through; cut corners symmetric; run parallel agents for the assets): water is a terrain type in the client (no global sheet): water tiles are flat at their own surface, every corner touching water sits on the waterline, the terrain shader draws ripples and gloss where its per-texel pick lands on water, so shores follow the same dithered border as other ground (WATER_LEVEL −0.25). Cut corners come in mirrored pairs (uo-buildings: one pattern per building, none / front pair / all four of each wing, a corner only with its twin; glazed diagonals all or none; plan diversity bar 60 %). The order: tools/world_props_order.py writes the contract assets/requests/world-props.toml and per-group lists (farm 124, flora 113, stone 107, remains 115, ruins 117, camps 128, shore 119, town 123, arcane 114), modelled by one parallel session per group on its own branch into assets/manifest.d/world_<group>.toml (brief assets/requests/world-props.md); world props are exempt from the demo village's every-asset rule (#53), the world bake places them. The dressing plan (docs/world-dressing.md): biome scatter in drifts, edges (banks, coasts, cliffs, forest edges), fields cut into plots with crops in four growth stages and farmsteads, road furniture and bridges, town dressing, a vignette per kind of place of interest, dungeon entrances, resource nodes, more grim props with danger; built in uo-atlas/src/dress/ (props.rs the order as a table, scatter.rs per region, sites.rs vignettes planned once before the bake like towns, town dressing in towns.rs): the nine groups were merged (1698 sprites, 156 MB), tools/world_props_usage.py checks every ordered prop has a rule. Rules: random clutter takes either orientation (props::twin); sites move off rock/water to open ground within 30 and put their landmark where it fits (Site::put_near); glades (no trees within 6–12, 26 on battlefields, which also trample fields); earlier sites win overlaps; no blocker on or next to a front door. Statics chunks list the extra tiles of multi-tile blockers; ruined walls, cliffs, palisades, barricades, bridge parapets, portcullises and sealed doors block their edge (blocked_edges); water props (props::on_water) stand on water (docks and boats not walkable yet). Client: sprites lower than 0.4 units cast no shadow shell; the sprite atlas starts with 12 layers (the catalog needs 10). Seed 1: 13.97 M statics (6.8 M before), 4232 sites; meadow 7.6–9.3 ms, farmland 10 ms, Skartor ≈ 15 ms, forest 24–26 ms (trees; 22 before) on a busy machine. Details: docs/world-dressing.md › Built.
  2. #103
    Towns from the atlas (user directive: next stage of the world, stage 3; amends #100's procedural town ground and #97's drawn towns): every settlement gets a plan (uo-atlas/src/towns.rs) computed once before the bake from the natural ground (game::natural); the ground painter, the bake and the atlas picture read the same plans. Bastide layout for the fixed camera: bands of plots run east–west and every band fronts south onto its street (the other side of a street is the gardens of the band below), cross streets north–south at irregular spacing (some skip a band), the last plot before a cross street may front east; the square is the middle band segment (fronts of the band above and the corner buildings west of it face it; low stalls on its south side), capitals and cities add a market square south of it; deep bands near the middle, shallower further out. Districts (civic, rich sector, gate, craft by water, common, outer) pick kinds by weights; each tier has required buildings (capital: temple, bank, guildhall, 3 inns, 2 barracks, 2 towers, 4 manors, 2 smithies, 2 warehouses; city, town, village, fort lists), caps, ordinary houses where a required one doesn't fit; 10–20 % of plots stay free for players (world.json plots). Walls: walled towns and forts are octagons (diagonal pieces, crenellations on stone/fieldstone at storey 1, palisades stepping for Aelwyn and the isles), a ring road inside, a 3-tile gate where each atlas road arrives (nearest straight side; a south gate if none), roads stop at the edge (glacis) and streets take over; unwalled towns get a street in from each road. Ground: level at the centre's height (flat to 1.04 × edge radius, 8-tile blend), cobbles/dirt/flagstone/grass/furrows by tier and use, forest/farmland cleared, brooks (≤ 2 tiles) built over, wider rivers forded, no plot on water. Yards by kind (smithy, inn stable yard, warehouse, workshop, barracks drill yard, temple graveyard, manor garden, vegetable patches), back-street fences with a gap, lamp posts on paved streets, shop signs. Statics format 2 (FORMAT 2): records carry facing, storey, roof, exact height (TEXEL_Z steps) and a building index into a per-chunk table of building boxes; Content::placement and ground_objects(): arenas (server and client prediction), blocking and line of fire use ground pieces only; the online client keeps baked heights and tags upper storeys and roofs Overhead. world.json lists buildings and plots. Tier::Fort radius 18 → 26 (barracks fit). Seed 1: 2665 buildings, 326 free plots; Skartor ≈ 150 buildings; Skartor's middle 9–15 ms/frame vs forest 22, meadow 4.3 (busy machine). Details: docs/towns.md.
  3. #102
    Diagonal walls, low walls, arrow slits and crenellations (ordered by the building generator, #101, in assets/requests/wall-pieces.md): every full wall set (stone, brick, wood, log, plaster, sandstone, marble, fieldstone, dungeon) gets diag_a, diag_b, diag_a_window, diag_b_window, low_{x,y,corner,post}, slit_{x,y}, crenel_{x,y,corner,post} (126 sprites), built by walls_kit from each set's own wall/post code. Diagonals run between the corner posts of the wall grid (1 texel inside the north/west tile edges; / from (9, 7) to (−7, −9) texels, \ from (−7, 7) to (9, −9)), not the tile's own diagonal, so they meet the straight walls and posts of a cut corner; the frame is sheared, not rotated (u = X − 1, 2 texels thick along Y, ends on X planes), so every vertex stays on the texel lattice and consecutive diagonal tiles join; window u −3..3 (8.5 texels along the slant ≈ the 8-texel window); outlines sit on half-pixel rows (softer, accepted). Parapets are the set's wall clipped to a profile (Frame.mask, convex parts cut by bisect_plane at whole texels/levels and closed) plus kit copings (run(cap=…), coping/post_cap hooks): low = body to 16 + coping to 18; crenel = body to 10, string course to 12, merlons 12..22 capped to 24, 4 texels merlon / 4 gap, x runs u −8..−4 and 0..4, y runs mirrored so the corner piece has an L-shaped corner merlon, posts are merlons. Slits: 2 texels at levels 22..36 through the back half, splayed 1 texel and 1 level on the front (room/camera) half; reveal ring from run(reveal=…). The village shows every new piece in a mason's yard (the catalog test, #53). Details: docs/uo-objects.md › walls.
  4. #101
    Building generator (user directives: buildings planned like a seasoned designer would, interesting and walkable, of different shapes (T, L, C and more), multi-storey where it fits, rooms full of meaning and props, new assets ordered when the catalog lacks them; the front door faces south or east, since the camera never turns; diagonal and low walls for cut corners and bay windows, balconies and loopholes): new pure crate uo-buildings, deterministic from a Spec (kind, style, plot, front, seed). 14 kinds (cottage, house, manor, farmhouse, inn, shop, smithy, workshop, warehouse, temple, bank, barracks, tower, guildhall) with a room program per storey (25 room kinds), shapes I, L, T, U, H, Z, cross, courtyard made of rectangular wings with storeys per wing. Wings are cut into strips that become rooms (main room first, at the front), upper storeys of inns, manors and barracks as a corridor with rooms off it; stairs are straight 4-tile flights under a room of the storey above (stairwell hole); the front door goes south, else east, never north or west, off corners and stairs (east-facing plots are planned as south fronts and flipped across the diagonal: _x↔_y, facings, _s↔_e, stair direction); inside doors along a tree from the entrance; windows every 3rd/4th outside tile, arrow slits on barracks and towers; visible corners cut diagonally by style (towers octagonal), bay windows on fronts; lower wings against taller ones may become terraces with low-wall parapets reached from upstairs, tower tops crenellated platforms; furniture from room templates (wall, _s/_e backed, wall mounts, table sets with chairs and things on top, counters, pews), each blocking piece kept only if the room stays connected and doors/stairs stay free; pieces: floors, outer and inner wall sets with corners and posts, doors, stairs, a hip roof per roofed wing. Checks: every room, door and stair reachable from the front door / landing; failing plans retry other variants. 14 kinds × 4 styles × 2 fronts × 12 seeds all pass, 81–96 distinct layouts of 96 per kind; ≈ 1 ms per building. Tools: uo-buildings (plan sheets per kind, report, missing-sprites.md), client --building-gallery <kinds> (real renderer, upper storeys and roofs as Overhead). New sprite names (wall_<m>_diag_a/_b[_window], _low_{x,y,corner,post}, _slit_{x,y}, _crenel_{x,y,corner,post}) are ordered in assets/requests/wall-pieces.md with their geometry contract; until baked the client draws placeholders. Walking upstairs is not possible yet (movement is 2D; task multi-level-movement); buildings are not in the baked world yet (towns, next stage). Details: docs/buildings.md.
  5. #100
    Felucca is a baked, static world; regions run on demand (renumbered from 98 when feat/world-atlas merged, with 99–102 → 101–104; 99 is left to the waitlist; user directives: build the playable map from the atlas, stage 1 of the world-building plan; then: no generation at run time, bake the whole map into static files and let the server send patches for what players destroy or change): uo-atlas --bake <dir> writes the world once: map.bin (per 64×64 region: terrain id + height per tile, deflated, indexed for random access; 12.8 MB), statics.bin (per region: sprite, tile, blocks; 6.2 M objects, 18.6 MB), world.json (name, seed, size, places, camp sites, hash of the data files) and atlas.json; ≈ 25 s at 16384². The per-tile rules (uo_atlas::ground + game) are shared by the atlas picture and the bake, so the atlas shows the map you walk. Ground: biome terrain with patches, z = elevation × 100 (mountains ≈ 12 units, rock = impassable as in UO, roads cross as passes), sea −2, lakes at their surface, rivers one below the banks; towns are levelled terraces with flagstone plaza, cobbled lanes and open plots (buildings come later); roads cobbled or dirt, causeways and fords over water until bridges exist. Statics: nature by biome and climate with stands, clearings and edge saplings, ≤ 1 blocking object per 2×2 tiles (always a path), none on roads/rivers/towns; real-size trees (#74) mean even dense forest has a tree on one cell in four; the combat demo's bandit camp at every atlas bandit camp, orc camp and monster lair (451). uo_world::baked reads the files (regions inflated lazily, kept), World { baked: true } reads tiles and content from it; nothing is generated at run time, player changes stay server patches (object rows, ObjectUpdate, #87). A world is baked once and shipped (the generator's float maths may differ in the last bits between platforms): Welcome gains world and world_hash, a client with another bake warns; the bake is not committed (/assets/world/ ignored), distribution comes with the first playtest. Demand-driven regions (amends #87/#90; worlds over 4096 regions): sims publish their players' regions and the 8 neighbours to a region_demand store table (TTL 20 s, refreshed every 3 s), gateways the region of a character they locate; placement takes only demanded regions (same rendezvous ranking); a region nobody demanded for 30 s is retired (objects checkpointed, lease released, never with players in it); smaller worlds keep every region running. uo-server --world <dir>; new characters start in a town of the realms by name hash, UO_SPAWN=x,y for development. The online bot wanders only to walkable goals. Details: docs/world-atlas.md › Baked world, docs/server-architecture.md § 6.6.
  6. #99
    Waitlist on Kit (user directive: use an email service, do not build our own; implements the waitlist left front-end only in #76): subscribers live in Kit (kit.com, free up to 10k), never in our store. The site's own form posts to POST /api/waitlist (web/src/lib/waitlist/), which creates the subscriber inactive and adds them to the waitlist form, whose double opt-in sends the confirmation. Consent (user choice, knowing the EU risk: no box, consent by default): the line under the button says joining gets you playtest invites, development news and offers, and submitting the form is the consent; no checkbox (an offers box was tried and dropped: tying offers to playtest access is formally "bundling" under GDPR art. 7(4), accepted as a small risk). Kit stores opt_in_at and opt_in_text (the exact line). source comes from ?ref=/utm_source (one ref per outreach channel). After joining, two optional questions (uo_played, interests) go to POST /api/waitlist/profile with an HMAC token valid 1 h, then the Discord invite is shown. Honeypot + best-effort per-IP limit. /privacy page. Keys only in env (KIT_API_KEY, KIT_FORM_ID, WAITLIST_SECRET), never NEXT_PUBLIC_; npm run kit:setup -w web lists forms and creates the custom fields (--apply). Details: docs/devlog.md › Waitlist.
  7. #98
    Hidden tree crowns (user directive: hide the lush crown when the character is near a tree so it never blocks the view, keep the trunk so it can be chopped): hiding by the existing debris.png mask alone leaves only a stump, because the trunk inside the crown was never baked. So trees are baked twice: manifest bare = true (uo-baker --bare, bake_sprite.py --bare) renders the same model again in the same session with every uo_debris = "vanish" material see-through and writes a bare twin to assets/sprites/<name>/bare/ (same frames, anchor and height range; sprite.json "bare": true; validated to draw only where the sprite does); all 13 leafy trees have one. The client packs the twin with the sprite and links each FrameRecord to its twin's (bare); a crown hides by writing bare_time/bare into the tree's effect slot, and the impostor shader swaps texels to the twin in a sprite-anchored Bayer 4×4 order over 0.35 s (prepass and main pass alike). Setting crowns: auto (default in every preset: the local player within 1.5 tiles of the trunk, or a crown texel over the player's body with the tree at most 1 tile behind; hysteresis 0.75 tiles / 10 texels), always (every crown hidden, twin record drawn outright), never. Shadows stay the whole tree's, picking uses the twin while hidden, hits/cracks/breaking unchanged. Code: uo-client/src/crowns.rs; details: docs/rendering.md › Hidden tree crowns, docs/sprite-format.md (bare).
  8. #97
    World atlas: a generated plan of the whole world (user directive: a huge Daggerfall-like world with many encounters and places, built in stages: first the continent plan with the geopolitics of two warring factions and designed resource scarcity, cities and dungeons, then the game map; generated, drawn as pixel art at 1 px = 1 tile; then: not one pancake continent but a very rugged big island with archipelagos around, the isles the richest land (dungeons, minerals) and complete anarchy): new crate uo-atlas, deterministic from the seed; the world is named Felucca (Atlas of Felucca, user directive). Scale: default 16384² tiles (1 tile = 1 m, #74), ≈ 68 min running edge to edge at 250 ms/tile (#92); macro grid of 16×16-tile cells for geography and placement, then a per-tile raster; densities per land area (--size scales content). Layout (designed, details noise): a warped main island with lobes, gulfs and fjords; a frontier curve across it, a valley carved along it carrying the great river the Sunder to the sea, a mountain barrier upstream; the cold northern plateau closed by a mountain wall, the wild north beyond; the warm wet south with the magic Elderwood and fens; an island chain off every corner plus one or two off the sides, cut by straits, never joined to the main island; one northern chain is the fire isles (the volcano). Polities: the Karrak Dominion (north: miners, smiths, stone castles) and the Aelwyn Concord (south: forest cantons) split the main island by culture-specific travel cost from their capitals, biased to the frontier; the March (contested band along the border, forts at every Sunder bridge and where roads leave a realm), the free city at the Sunder's mouth, the wilds (beyond the northern wall, snow peaks, deep fens) and the isles (every island off the main one: no law at all). Faction names are working names, all other names are generated per culture (Karrish, Aelwyn, Old Empire, orcish). Scarcity (checked by tests on three seeds): Karrak holds the main island's gold, gems and rare ores (UO coloured ores), granite, marble, pine; Aelwyn the bow woods (yew, heartwood, bloodwood; ML woods), most grain and the herbal reagents, and no rare ore at all; the isles hold the largest share of the best of both (valorite, gems, heartwood, bloodwood, frostwood, black pearl) and all the sulfurous ash (kegs, #71). Provinces around every capital, city, March town and the free city (future fiefs, #77), named wild regions, one lawless province per archipelago. Places: capitals, cities, towns, villages by habitability with spacing; highways/roads/tracks by A* that reuse roads, never across the sea; the ruined capital of the Old Empire as the great March dungeon; ≈ 170 dungeons (11 kinds by land), ≈ 2700 sites (18 kinds), danger 1–5 per cell, 15 encounter zones with weighted tables of 33 encounter types. Rendering: indexed palette in the calm warm look (#62), per-tile biome with Bayer-dithered borders (#40), hard-banded hillshade, tree crowns, fields, pools, lava cracks, rivers by drained area, towns at real size (walls, streets, roofs by culture, castle or great tree, piers), forts, dungeon mouths, site marks. Output (not committed, regenerated): 1:1 and 1:4 tile pyramids (1024² PNG chunks), overview, realm/danger overlays, per-cell lookup images, atlas.json, and an index.html viewer (pan/zoom, overlays, deposits by resource, search, hover details with the local encounter table, realm and scarcity tabs, scale bar in running time). The playable map is built from it by #98. Details: docs/world-atlas.md.
  9. #96
    One control scheme and one HUD in every mode (user directive: online WASD ran while offline it panned the camera; same controls and HUD whatever the mode; amends #53/#70's keys and #91's online-only running): uo-client/src/controls.rs turns mouse and keys into Intents (walk, step, attack a mobile/object, cast, guard, potion, hold, next target, again) and a held running direction (Held: right mouse button towards the cursor, WASD in screen directions, as UO); the active mode applies them: roam.rs offline outside a battle (paths around walls and props, held running one tile at a time, a click on a destructible object hits it), combat/input.rs in an offline battle (battle orders; a held direction orders one Move step whenever nothing else is ordered, as the server does), online/input.rs as server commands with prediction. Each mode fills controls::Scope (pickable mobiles, known spells, enabled, in a fight) before the controls run. hud.rs is the one HUD (status panel top left, vitals, spell bar, target card, log, outcome banner, overhead bars, floating text) drawn from HudModel and GameLog, which the active mode fills; an offline battle adds its timeline (combat/hud.rs). Keys: arrows pan the camera in every mode (WASD never pans), H cycles roofs (R is always hold), numpad steps everywhere, Esc disarms a spell or, with nothing armed, quits (never in a fight), Enter = fight again offline / greeting online, Shift + right click = the offline debug blast (a plain right click no longer hits objects; left click does).
  10. #95
    Moonlit night, warm white lights (user directive: the black night is too dark, shade it dark blue as under the moon; artificial light too orange, should just be warm; supersedes #63's black night): the night Phase has a weak cool moon (0.25, 0.4, 1 × 0.05) and a dim blue sky (20), exposure +0.5 EV; TOON_T0 0.003 (black only as a guard). New per-phase dim tint (Phase::dim_tint + dim_tint_amount, uploaded as Shading::dim_tint, applied in toon_shade): texels in the two darkest toon bands go towards that colour at their own luminance (night (0.3, 0.45, 1) × 0.65, other phases 0; user feedback: 0.9 was too monochrome, shadows keep light hints of colour), so moonlit surfaces read blue whatever their albedo while firelight (brighter bands) keeps its hue. The character light floor (MIN_LIGHT, #48) and rim are dim light too: they take the tint, and the floor only tops the light level up by luminance (no per-channel max), so characters go moonlit blue in the dark and stay warm by a fire. Artificial lights (sprite lights, torches, campfire; not combat FX) pass through Look::light_color: saturation light_saturation 0.6 in linear light, luminance kept. The directional light fades out around the sun-to-moon direction switch (≈ 5:00, 19:00) so shadows never jump. Details: docs/rendering.md › Look.
  11. #94
    Held running is client-driven, as in UO (user report: constant corrections running circles on WASD, alone on a local server; amends #91): the server used to choose a held heading's next step ahead of time while the client chose it when stepping, so the first step after every turn differed and the prediction replanned (21 times in 40 s of circling). Now out of a fight the client drives held steps: each predicted step (heading rule with sliding, uo_world::walk::heading_step) is sent as Step { dir }; the server queues up to MAX_QUEUED_STEPS 4, takes them in order one per step time as soon as nothing else is ordered, checks each (walkable, free) and refuses a bad one with StepRejected (queue dropped, client rolls back). In a fight, where nothing is predicted, the held direction stays server-driven (Move { dir }). Predicted steps keep their rhythm (a late frame no longer pushes every later step back). Result: circling 40 s, 128 predicted steps, 0 corrections.
  12. #93
    Speed and item properties inspired by UO AOS/ML (user directive; design direction, not implemented yet; amends #52's Renaissance-only ruleset and #68's fixed costs): Renaissance is too rigid — characters should differ by gear, and chasing better properties is part of the grind and the pace of play. Combat timing follows AOS/Mondain's Legacy: Swing Speed Increase (percent, capped), Faster Casting and Faster Cast Recovery (points, capped), and the other AOS-style item properties as further Attr modifiers on items and effects (#52's modifier system). Times are computed in milliseconds and rounded down to the quantum (UO's 0.25 s breakpoints: a bonus either saves a whole quantum or nothing), roughly: swing = floor_q(weapon base × stamina factor × 100 / (100 + SSI)) with a minimum swing; cast = (2 + circle) q − FC; recovery = (base − FCR) q (the base recovery must rise above today's 2 AP so FCR has something to cut). The quantum stays AP_MS = 250 ms for now; it is a tuning knob: a later pass may lower it to 150–200 ms with higher base costs for swings and casts. Caps, exact formulas, which properties exist and how loot rolls them are open (docs/combat.md › Speed properties).
  13. #92
    Run out of a fight (user feedback: the character walks, movement should be faster, especially outside combat; amends #68's single step cost): in the open world a player steps at 1 AP (250 ms, RUN_AP) out of a fight and at 2 AP (500 ms, STEP_AP) in one — in a fight = attacked or attacking within the last 10 s (the server's battle-pinning window). Per-fighter Fighter::step_ms in uo-combat (default 2 AP, so the offline demo and mobs are unchanged); the server publishes the player's current step time in EntityState::step_ms and the client times its predicted steps by it; the run animation follows from ground speed (#48).
  14. #91
    UO-style running (user directive: hold the right mouse button and the character runs towards the cursor, duplicated on WASD): protocol Command::Move { dir: Option<Direction> } holds a heading until dir: null; the server steps it one tile per step time (2 AP) whenever the player has no other order (a held heading and a WalkTo come before auto-attacking back), sliding along obstacles: straight on, else the two neighbouring directions (uo_world::walk::heading_step, shared with the client's prediction, which predicts one step ahead). Any walk, attack or cast order ends the heading; the heading survives handoffs. Client: right mouse held = direction from the character to the cursor (8 sectors, half-tile dead zone), WASD = screen directions (W up the screen), sent only when the direction changes; online the camera pans with the arrow keys only. Also: walks are planned leg by leg by one shared planner (uo_world::walk::{waypoint, route}) on server and client, walks keep their planned route, command numbers are per session. --bot run exercises it.
  15. #90
    Ownership timings and tick scheduling (from the full M7 run on ScyllaDB: 2000 bots, 2 Bevy clients, crash, gateway loss, rolling restart; amends #87's 5-s leases renewed every second): membership is the fast liveness signal — a plain heartbeat every 500 ms, TTL 3 s, and a sim fences all its regions when no heartbeat succeeded for 2.5 s; region leases last 10 s, renewed every 3 s (a third of the LWT); a region whose holder dropped out of the membership is taken over at once by compare-and-set on (node, epoch); heartbeat, renewal and placement are independent loops; heartbeats carry stopping (stopping sims take no regions); DDL only when tables are missing (a DDL stalls LWT on Scylla); a panicking sim core gives up its membership and the process exits; drains give up after 6 s without a ready. Tick: regions tick on the sim thread when the summed work is < 8 ms, else on a pool of half the cores (UO_TICK_THREADS); on macOS the tick thread runs under the time-constraint policy (50 ms period, 10 ms CPU, 25 ms constraint) and the pool at user-interactive QoS; the load generator runs at utility QoS. Path searches fail at once when no free goal tile is reachable (arena connected components) and failed searches aren't repeated from the same tile for 1 s. Latency histograms: four buckets per octave; scenario reports sum each sim's histogram across restarts. Result: tick p99 4 ms, failover 3.6–5.2 s, rolling 1.7–2 s per sim, 0 duplicates, all bots back (docs/server-performance.md, docs/server-m7-report.json).
  16. #89
    Load and chaos testing of the cluster (user directive: test instance switching with several server instances and the load of many headless clients, like combat-auto): uo-loadtest (new crate) runs a swarm of protocol bots in one process (uo_sdk::bot: combat, wander, duel, siege, churn) and scenarios that start the cluster (--store scylla://…: node processes; --store inproc: nodes in one process on one MemStore with real TCP; mem: one all-roles node), optional real uo-client --headless --bot combat-auto clients, chaos steps (secs:kill|stop|start:node, secs:rolling) and checks every second through /state: no player simulated by two nodes in two consecutive samples (one sample can catch a handoff between two reads: counted as transient), every region owned again (failover window), every bot back after losing its node or gateway; report.json with tick quantiles per 1-s window. Results (docs/server-performance.md): 1000 bots on 3 sim + 2 gateway processes over Apache Cassandra 5 and 2000 bots in process, through a sim crash, a gateway crash and a rolling restart: 0 duplicates, failover 6–7 s, rolling drain 0.2–2 s per sim, all bots back. Dev store: ScyllaDB from deploy/docker-compose.yml (on this machine docker pull hangs in Docker Desktop's credential helper: fetch the image with crane and an empty DOCKER_CONFIG, then docker load) or Cassandra from Homebrew (MAX_HEAP_SIZE=1500M cassandra -f), same driver and schema. Final M7 (#90): 3 sims + 2 gateways as processes on ScyllaDB, 2000 bots + 2 Bevy clients, all targets met. Measure on a busy machine only within one run (#28/#49).
  17. #88
    Online client v2 (with #87): uo-client --server ws://a[,ws://b] --name N [--bot combat-auto] [--headless] [--screenshot out.png --seconds S] [--record out.mp4] speaks protocol v2 (net.rs thread: Login, Resume{token} on the next URL with 250 ms–5 s backoff, Login fallback; ping every 2 s with a clock offset). Online mode never builds the whole map: a 192×192 terrain window (GameMap carries an origin; height outside the window falls back to the generated map) re-centred when the player is 48 tiles from its centre; region statics stream in from uo_world::cached_content within 32 tiles and out past 48; ObjectUpdates drive the #66 damage/cracks/breaks, open doors and runtime objects (Wall of Stone, kegs). Mobiles are dressed from their equipment (bandit roster sheets, archetype outfits as fallback), step-timed from Moved events, with deaths, corpses and respawns; combat events reuse the offline demo's presentation (combat/present.rs helpers shared, offline unchanged); HUD: status, vitals, spell bar (from Welcome.spells), target card, log, overhead bars; input as in the combat demo. --bot combat-auto plays by itself (nearest bandit, known spells, heal/potion when low, next unvisited camp). Terrain fixes found on the way: materials built after startup got default Shading (black shadows) and the terrain noise hash now wraps every 289 cells (precision at large coordinates). Code: uo-client/src/online/.
  18. #87
    Server cluster: gateways + sims over a shared store (every region leased and ticking: small worlds only since #100, big worlds are demand-driven) (user directive: server and client working together without uo_ledger, built for tens of thousands of sessions on several instances sharing a Cassandra-class database, instance switching from the start, tested with several instances and headless bots; amends #7/#11/#17: the ledger is off, inventories live in the store): live state is in memory with exactly one owner, the store holds durable state, ownership and membership (a database can't be the 50-ms state bus: LWW, no transactions). One uo-server binary, roles gateway (client WebSockets, sessions, area of interest radius 18, routing; no game state) and sim (regions), --role all --store mem for one process. The map (1024² by default) is cut into regions of 64×64 (uo-world, new shared crate: regions, per-tile map windows, deterministic content per region (bandit camps from the combat demo, nature) with stable ObjectKeys, the combat arena builder); each region is one open-world uo_combat::Battle (Battle::open: no end, no escape, mobs leashed to home, remove/slot reuse, Side u32) over the region plus a 12-tile margin, ticked at 20 Hz = SIM_STEP, regions in parallel. Ownership (timings superseded by #90): region leases (compare-and-set, TTL 5 s, renewed every 1 s, epoch + 1 per new holder, self-fencing 1 s before expiry), placed by rendezvous hashing per region over live sims (the top-ranked takes a free region at once, rank k after k·1.5 s = failover; a region whose best sim is another node up ≥ 5 s is drained there = rebalancing and rolling restart); character ownership char_owner (node, gen) by compare-and-set, the commit point of every handoff; state writes are plain LWW writes USING TIMESTAMP gen·2⁴⁰ + n (objects: lease epoch), so a former owner's late writes always lose. Handoff: a player ≥ 2 tiles past the border and out of combat for 10 s (or 10 tiles in, whatever) leaves the battle, the destination claims gen + 1, checkpoints, admits it and re-routes its gateway; commands carry a per-session seq (no duplicates), arrive buffered/forwarded; 2-s timeout resolved by reading the owner. Battle pinning: attacking an entity of a neighbouring region moves the attacker into that region's battle when it stands in its arena. Object damage in a margin goes to the owning region (ObjectHits), object changes go to the neighbours' arenas. Planned migration (/drain, SIGTERM): freeze, checkpoint, transfer the lease, the new owner loads and reports ready, then the players are handed over (≈ 50 ms per region). Failover: leases expire, the next sims take the regions, gateways re-attach their sessions (state ≤ 15 s old, checkpoint interval); a gateway loss costs a Resume (token, any gateway; characters linger 30 s). Store: Store trait, MemStore (exact CAS/LWW/TTL semantics, multi-node tests in one process) and ScyllaDB/Cassandra (scylla driver, LWT with IF expires_ms < now so lease epochs never expire, vnode keyspace). Internal protocol: MessagePack frames over TCP, a link per peer, PeerDown on loss. Protocol v2 (PROTOCOL_VERSION 2): Snapshot + per-tick Delta of the area of interest (JSON assembled from per-entity-version fragments), Resume, combat orders (Attack, Cast, Guard, Potion, Hold, Open, Plant) and CombatEvent (= uo_combat::Event with entity ids, object keys), ObjectUpdate, mobs, vitals. Players are blessed warriors / archers / mages by name hash; Gather/Craft answer "ledger off". uo-sdk: async Session (login, resume/failover over a gateway list, world View) and bot behaviours. Admin HTTP: /metrics (Prometheus text), /state, /drain, /shutdown. Details: docs/server-architecture.md.
  19. #86
    Mega Explosion last stand (user directive: a second before dying the hero casts a mega explosion that breaks almost the whole cabin; kill everyone; bandits kill him faster): Spell::MegaExplosion (hotkey 7, SpellKind::Cataclysm): no mana, only at ≤ 25 % health, once per fight, 0.6 s channel that can't fizzle (the caster can't drop below 1 hit while channelling), radius 7: 120–160 damage to everyone including the caster, 20 hits to every obstacle; a killing blow on a fighter who has it triggers it automatically; Outcome::NoSurvivors. Client: house-sized fireball and a ring of secondary blasts, camera shake (CameraState::shake); 2.5 s slow motion removed (user: it looked like lag). Cabin bandits: melee skills/Tactics 100 and 1.6x melee weapon damage (roster only, the catalog keeps UO values). Typical run: breach 15 s, seal 17 s, everyone dead at 29.6 s, about 50 objects broken.
  20. #85
    Log walls without grey chinking (user directive: the joint between logs should be darker wood, not grey): wallset_log.py CHINK is now a dark wood brown (linear 0.06, 0.035, 0.016, darker than the darkest log) instead of pale mortar (0.52, 0.47, 0.37); all eight wall_log_* sprites re-baked.
  21. #84
    --no-hud (user directive: record without the HUD, vertical 9:16): hides the combat HUD, floating text and gizmo overlays; vertical videos via --record out.mp4 --no-hud --width 1080 --height 1920 --zoom 3.
  22. #83
    Life-size bear-skin rug (user directive: the bear skin should fit a natural bear against the 1 m tile): rug_fur is modelled 2.25x larger (pelt ≈ 2.2 x 2.1 m), centred between four tiles with footprint = [2, 2] like rug_blue_large (origin on the north-west tile), plan points on whole texels, claws on the paw tips. In the cabin it lies on the open floor beside the table (interior 4,1).
  23. #82
    A changelog entry for every change (user directive; widens #78/#81, where CI only required fragments for crates/, assets/, chain/): every change that lands on main, by PR or as a direct commit, in any area (game, assets, chain, site, bot, tooling, CI, docs, CLAUDE.md), carries its changelog fragment in the same change; one fragment per PR or logical change, written with it, never backfilled later. The only exception is an edit that touches nothing but the devlog content itself (changes/, blog/, tasks/, roadmap.yaml: a typo in a post, a task moving columns). New change type dev (label Internal, append-only in CHANGE_TYPES) for changes players do not see; its summary is still plain words. CI (.github/scripts/check-pr-content.mjs) now fails every PR without a fragment outside that exception. Type labels moved to devlog-core (CHANGE_TYPE_LABELS), shared by site and bot.
  24. #81
    Public development pipeline built (implements #78; 79–80 are taken by feat/combat-demo): npm workspaces at the repo root (web/, bot/, packages/devlog-core/, Node 22+). Site web/: Next.js 16 App Router, Tailwind v4, every page static; the landing page is a 1:1 port of site/ (screenshot diff web/scripts/compare-home.ts at 1440 @1x/@2x, 1024 and 390 @3x: every viewport reached 0 differing pixels in some run; other runs show sparse speckles on downscaled photos, Chromium resampling noise that the old page also shows against itself), site/ stays until the deploy. Content compiled by Velite with typed schemas (web/velite.config.ts) from changes/, blog/, docs/*.md, tasks/, roadmap.yaml and the CLAUDE.md decision table (/decisions); cross-file checks fail the build (unknown status, area, milestone, decision, fragment, media key). Routes /changelog (ISO weeks, no comments), /blog, /rss.xml, /feed.json (JSON Feed 1.1, posts + changes with a _felucca extension: the bot's input), /docs, /decisions, /roadmap (+ /roadmap/<id>). #NN in markdown links to /decisions#dNN; links to source files become plain text (private repo). Comments: thread messages fetched from the Discord REST API with force-cache + tag discord:thread:<id>, slug→thread from Upstash/Vercel KV (JSON file fallback) tagged discord:slug:<slug>; own Discord-markdown parser rendering React elements only; signed POST /api/revalidate (x-felucca-signature: t=…,v1=HMAC-SHA256("t.body"), 5 min window, discord:* tags only) calls revalidateTag(tag, { expire: 0 }); daily Vercel cron fallback. Bot in TypeScript/discord.js (shares code with the site; Rust gains nothing for I/O glue): Vercel deployment.succeeded webhook → reads /feed.json → plain #changes messages, forum post per blog post (slug→thread to the KV), message events in known threads → debounced revalidation; first run marks history as announced (ANNOUNCE_SINCE); Docker/systemd/Caddy files in bot/deploy/. Media: originals outside git (web/.media/originals, MEDIA_ORIGINALS) plus docs/screenshots/; sharp variants by class (pixel: 1/k nearest WebP; logo: ×k nearest PNG; art: Lanczos widths; file: copied), web-ready masters published byte for byte, content-hash names, web/media-manifest.json committed; R2 upload over the S3 API, never overwriting, immutable caching; next/image custom loader (variants, Discord ?size=, Cloudflare transforms) used through the server component Img; ![](media:<key>) in markdown. Vercel root web/, ignored build step web/scripts/vercel-ignore-build.sh. Rules: see Public development below. The roadmap and tasks are a starting set seeded from open items. Nothing is deployed, uploaded or posted yet (task deploy-devlog). Details: docs/devlog.md.
  25. #80
    Cabin scene mirrored: the entrance faces the camera (user directive: entrance from the south to show the cabin better): the door (and the hero's window) are in the cabin's south front, the yard gate, dirt path and bandit spawns are south (roster offsets mirrored), the fireplace and bed stay on the back wall so the hero falls back behind the table; the Cutaway hides only the east wall (user feedback: all walls stay visible; the cabin uses no cutaway, statics::Cutaway remains for other scenes), the roof hides while the player is in the yard. With real-size nature (#74, trees 10–16 units) the forest keeps tall trees only well behind the scene (screen depth < −14 from the hero) or at the sides (|x−y offset| ≥ 16), saplings a little closer, undergrowth (bushes, ferns, shrubs, grass, stumps, logs, rocks) in front. Typical run: keg 11.3 s, breach 15.3 s, seal 16.7 s, hero dead ≈ 38 s.
  26. #79
    The cabin hero holds the house and falls (user directive: he is no superhero and should die soon after the bandits break in; he falls back to the wall opposite the door, behind the table, and puts a wall where the blown door was): supersedes #72's back door and indoor escape. Ranger indoors never runs: on an enemy keg it falls back to the reachable indoor tile farthest from the keg with the most cover between and no outdoor tile within 2 with a clear line (polearms through windows); once melee can get inside it casts one Wall of Stone, aimed at the doorstep/first tile in (±1 tile shifts) and checked by simulating the walls on a cloned Arena: it must cut every melee bandit off from getting inside, or else at least the nearest. A keg blast staggers fighters within 2 tiles for 1.5 s (STAGGER_MS); AI fighters continuing a walk skip the reaction delay. The cabin hero is Str 75, Archery/Tactics 80, no potions, no Heal, no champion; the cabin has no back door. Typical run: keg 12.4 s, breach 16.4 s, seal 18.75 s, bandits in about 31 s, hero dead about 43 s.
  27. #78
    Public development pipeline (planned, user directive): the site moves to Next.js (App Router, static pages, revalidateTag) on Vercel, deployed on merge to main (ignored build step for non-site changes). Content is markdown with typed schemas: changes/ (one fragment per PR, enforced by CI for PRs touching crates/, assets/, chain/), blog/ (posted often, not weekly: a post for every new feature, scheduled digests compiled from fragments, and every new game-design decision; written by the LLM in the same PR, published by the merge), docs from docs/*.md, decisions to be split into docs/decisions/, kanban in the repo, never an external tracker (the LLM reads it without connectors): roadmap.yaml holds columns and milestones, each task is its own file tasks/<id>.md (frontmatter: status, milestone, area, links; parallel sessions do not conflict), the site renders the board; RSS and feed.json. The repo stays private for now; the site publishes the content. Comments live in Discord, for blog posts only: changelog fragments are announced in a Discord #changes channel with no threads and no comments on the site; every blog post is mirrored as a forum post and the site renders that thread's messages with an eternal cache tagged per thread, invalidated through a signed POST /api/revalidate by an always-on Gateway bot (after each deploy it posts new fragments to #changes and creates forum posts for new blog posts; slug→thread in a KV; cron fallback; Message Content intent; channel notice; sanitized Discord markdown). Media on Cloudflare R2 (content-hash names, immutable cache, video included); variants pre-built with sharp (pixel art: whole-number downscales, nearest, WebP), media-manifest.json in the repo, originals outside git; next/image uses a custom loader (no Vercel Image Optimization); Cloudflare Image Transformations only for arbitrary images; Discord avatars via its CDN ?size=. The Discord bot runs on a mini VPS.
  28. #77
    Game positioning (user directive): Felucca Online is pitched as the MMORPG old-school players dreamed of, "UO as we imagined it". Top-priority pillar: a fully dynamic world that players build and destroy; the game is in essence a vassal simulator: players raise towns, swear fealty or gather vassals, and lay siege to rival towns. The website leads with this (one-phrase nostalgic headline, building/siege first in features); other systems (skills, economy, combat, automation) support it. Player building and the lord/vassal layer are design direction, not built yet; destruction (#66) and combat with cover (#68) are. Recorded in PRODUCT.md.
  29. #76
    Project website (user directive): site/index.html, English, static HTML with Tailwind v4 (CLI: site/src/input.css → site/assets/site.css), the game's public name Felucca Online (logo docs/concepts/logo-felucca-b2.png). Built comp-first with the impeccable skill (PRODUCT.md, .impeccable/: approved comp mocks/comp-a2.png, direction contract in surfaces/). Look: flat modern UI, fantasy only in the imagery (user directive): dusk-violet ground from the logo, warm off-white text, one ember action colour, Kumbh Sans self-hosted. Audience: players; action: playtest waitlist (front-end only until data-waitlist on <html> names an endpoint on Kit since #99); Solana barely mentioned; social/CTA links are # placeholders. Hero = the user's panorama as a still (desktop) and a 9:16 repaint of it (docs/concepts/village-portrait-9x16.png, phones); the Seedance 2.5 loop (same first and last frame, plus a 0.5 s crossfade at the seam) is set aside until a livelier version is generated (user directive). The logo is the user's pixel-art wordmark site/assets/img/logo-type-big.png (6 px art pixel), shipped as a 1:1 master and scaled only by whole multiples with image-rendering: pixelated. The site menu is hidden over the first screen and slides in once the visitor scrolls past it (user directive). Gameplay images are real client captures only (--screenshot, UO_FREEZE_TIME=1; combat from the feat/combat-demo build's --record), never AI-drawn. Generated art goes through kie.ai (GPT Image 2.5 Flare image-to-image, Seedance 2.5, recraft/remove-background; key never committed) and every shipped raster carries its provenance (impeccable embed-prompt).
  30. #75
    See-through circles around mobiles (user directive: cut a dithered circle through foliage and walls in front of the character and other mobiles): uo-client/src/cutout.rs turns every mobile in view (SpriteView without Static) into a circle in render-target texels (centre at chest height CHEST 0.95, exact via CameraState::render_origin), with its view nearness and feet height; ≤ MAX_CUTOUTS 32, nearest the screen centre first, in a fixed-size storage buffer on the impostor material (binding 108), rewritten only on change, and mirrored into the outline uniform. The impostor shader discards a texel inside a circle (RADIUS 28 texels) that is nearer the camera than the mobile by DEPTH_MARGIN 0.5 and higher than its feet by MIN_HEIGHT 0.3 (floors/rugs/low plants stay): fully cut core (CORE = half the radius), Bayer 4×4 dithered rim in whole texels anchored to the circle centre (moves with the character). The test lives in reconstruct, so prepass and main pass agree (no clear-colour holes, cf. #73); inside a circle the outline pass ignores nearer sprite neighbours (characters keep theirs). Shadow shells are not cut. Key 9, --disable see_through, user setting see_through (on in every preset). Frame cost not measured yet (machine busy with a parallel session). Details: docs/rendering.md › See-through circles.
  31. #74
    Real-size nature from the Quaternius CC0 nature packs (user directive: textured 3D nature models from the downloaded packs, trees about three times taller, real size 10+ m; amends #15/#50's procedural nature and #21's "alpha cut-outs are not baked"): tools/blender/import_nature.py converts the used models of Stylized Nature MegaKit (Standard) and Ultimate Stylized Nature into assets/models/nature/{megakit,ultimate}/ (glTF, materials rebuilt as plain Principled with the colour texture, MegaKit bark × vertex AO, leaf alpha MASK; normal maps dropped; colour 512², bark 256²; originals never committed; credit "models by Quaternius"). tools/blender/models/nature/packs.py bakes trees, bushes, shrub, fern, tall grass, flowers, mushrooms and single rocks: one pack model per variant (client picks per tile), uniform scale to real size (1 unit = 1 m: grown trees 10–17 m, apple 6.5–8, sapling 3.5–4.5; trees keep footprint = [1, 1]), seeded yaw, cut at the ground; leaf cards recoloured per species, one tone per card (lighter on the sun side and top). Willow = pack crown + hanging leaf-card curtains. rocks, mushroom_giant, cacti, reeds, vines, lily pads, stump, logs stay procedural; trees.py is retired. Baker: a linked Principled Alpha is clipped at 0.5 in every pass (albedo, normal, height, back height, material, emissive, debris; peel mode ignores it), transparent bounces 256; material property uo_normals = "keep" un-flips back-facing shading normals so double-sided cards keep their custom crown normals. Pack geometry is off the texel lattice (nature stays the softest group, #60). Next step (user directive): cut out sprites that overlap mobiles (player/NPCs) so tall crowns never hide them. Details: docs/uo-objects.md › nature, docs/asset-pipeline.md, assets/models/nature/README.md.
  32. #73
    Impostor main-pass depth nudge (user report: black specks on objects that stick to them when the camera moves, more of them farther from the camera and in busy scenes): the impostor main pass adds DEPTH_NUDGE = 4e-6 (≈ 0.0008 world units towards the camera, ≈ 2 % of a texel) to its frag_depth. Cause: the depth prepass and the main pass reconstruct the same depth in two shaders, Metal compiles with fast math, so they round differently in the last bits; a main-pass fragment a hair farther than its own prepass depth fails GreaterEqual and leaves the clear colour. Worse beyond the camera focus (depth < 0.5: finer float steps) and far from the world origin (bigger coordinates). Village at focus 64,64: 37 k speck pixels → 0, rest of the image unchanged. Rule: any shader that writes frag_depth in both prepass and main pass must bias the main pass towards the camera (numbers 69–72 were taken by parallel branches).
  33. #72
    Combat autoplay story and video recording (user directive: autoplay where the hero runs out of the door and casts Wall of Stone to protect himself; record a video): rules gain openable doors (Order::Open, 2 AP; open doors stop blocking/covering; DoorOpened event; client swaps in the _open sprite), path crossing policy Cross::{Nothing, Break(ap), Doors}, Arena::indoor (tiles with floors) and Archetype::Ranger for the --combat-auto hero with a bow and spells: best clear shot through the window, Lightning only with brimming mana, leaves a building through doors when melee can reach it, Wall of Stone across the nearest melee's way in within 5 steps (15 s cooldown, only where ≥ 2 pieces fit, else it runs into the open first; Battle::wall_edges), Explosion on two or more bunched bandits outdoors, kites below 40 % health. The cabin gets a west back door (removed by #79); cabin halberdiers Str 70 and poacher Str 50. --record out.mp4 [--seconds N]: offscreen render with HUD (the present camera targets an image and is the default UI camera), TimeUpdateStrategy::ManualDuration(1/30 s), frames piped in order to ffmpeg (H.264 CRF 18, 30 fps); the battle waits for the first frame and recording ends 3 s after it. Details: docs/combat.md › Autoplay, Recording.
  34. #71
    Forest cabin assault scene (user directive: robbers blow the cabin door and frame with an explosion, the hero shoots through a window with a bow, halberds wreck the furniture, fences can be broken, as much destruction as possible, a pretty, full location): --combat-demo [cabin|camp], cabin is the default (combat/cabin.rs, built with the village's now crate-visible Plan/Building). Rules (uo-combat): obstacles get a Kind (wall, window, door, fence, object, charge); windows are low cover and low cover touching the shooter is ignored for its shots; powder kegs (Order::Plant, 6 AP, 4 s fuse, or set off by any hit; blast radius 1: 25–40 damage, 10 hits to every obstacle); two-handed melee sweeps (SWEEP_HITS 2 to every object within 1 of the target in reach); Archetype::Sapper (goes for the door nearest the target, breaking fences on the way, plants, retreats); brutes break fences at once but wait clear of the door while a sapper has a keg; everyone steps away from lit kegs; archers don't shoot cover they touch. Events: Hit.cause (weapon/spell/blast), ObstacleSpawned.spawn (wall/charge), Detonated. Client: doorways/open doors/gates/posts become non-blocking obstacles so blasts break the frame; only solid tile objects give cover; a broken obstacle always breaks on screen; kegs are keg statics with a fuse light; statics::Cutaway walls (the cabin's south/east) and the roof hide while the player is in the yard. --headless --screenshot captures the scene render without a window. Details: docs/combat.md.
  35. #70
    Hotkeys on numbers and QWERTY (user directive: F keys are awkward on MacBooks; debug toggles live in settings): combat spells on 1–6, Q potion, E guard, R hold (R cycles roofs only outside a battle; roofs on H since #96), Tab next target, Enter fight again, Esc disarms a spell (quits only outside a battle). The effect toggle keys 1–8 and the debug-view key V are removed (settings window on O; --debug-view, --disable/--enable for runs). WASD/F camera (WASD runs since #96; arrows pan) and the time-of-day keys stay.
  36. #69
    Combat demo implemented (user directive: client-only showcase of #68, no server/ledger): rules in the pure, deterministic crates/uo-combat (integer ms, 50 ms steps, seeded RNG; Arena with edge/tile obstacles, supercover line of fire, A*, orders, pending blows/projectiles, two-phase spells, Wall of Stone, explosions, archetype AI (brute breaks through walls, archer seeks a clear line, caster walls/heals), event stream, previews); WeaponDef gains ap (dagger 4, longsword 7, mace 8, staff/tools 9, bow 8) and the halberd (id 305, 12 AP, reach 2). Client uo-client/src/combat/: --combat-demo / --combat-auto put a bandit camp south of the village (village moved north in this mode), the arena is built from world statics (hits from Durability, cover from baked height_range, iron bars low, rope none), events drive Actor steps, anim::OneShot clips, projectiles/bolts/explosions, rising wall_fieldstone pieces, HitStatic and floating text; box-mesh weapon props on the hand bones (right-hand props turned 180° so blades point up, user feedback); EVE-style HUD (8 s timeline with READY gutter and action ghost, vitals, spell bar, target card, log) and gizmo overlays. Keys: F1–F6 spells, G/Q/H, F9 restart (superseded by #70), Tab; free walking and debug right-click destruction are off in battle. Demo hero gets a champion effect (+120 max hits); bandit skills 70; alert range 10. FX lights are separate unscaled entities; window --screenshot captures go black if the window loses focus mid-run. Details: docs/combat.md › Demo prototype.
  37. #68
    Combat = real-time tactics on a tempo timeline (user directive: Fallout 1–2 × FFX; amends #55): the fight happens on the world's tiles (no battle screen); every action costs AP, and 1 AP = 250 ms of real recovery (no pool, no banking: act → ready_at = now + cost); the clock never pauses, not even offline; you may queue the next order during recovery; with none, a ready combatant auto-attacks its target (UO swing timer). Starting costs: step 2 AP per tile, dagger 4, halberd 12 (reach 2), so a dagger swings three times per halberd swing; casts channel 2 + circle AP (damage can fizzle; line of fire checked when the spell resolves) + 2 AP recovery. Paths are tile steps (each its own action) blocked by blocked edges and statics. Projectiles and targeted spells need a line of fire; destructible statics on it are cover, classed by baked height (full: always stops, low: stops 50 %); a stopped shot hits the obstacle (#66 durability hits), anyone can attack an obstacle directly, and broken objects stop giving cover and blocking at once. Melee ignores cover but not walls between tiles. First build is a client-only showcase (--combat-demo in the village vs dagger rogue, halberd guard, archer, mage; rules in a pure uo-combat crate, client in uo-client/src/combat/); real time makes ephemeral rollups a requirement for the later on-chain version. Details: docs/combat.md.
  38. #67
    User settings (user directive: debris physics and debris as quality options; prototype of a settings system): uo-client/src/settings.rs, UserSettings resource saved as TOML in the platform config dir (UO_SETTINGS / --settings override), every field defaulted so old files load; one graphics section: effect switches (feed the render Toggles; keys 1–8 stay session-only debug toggles removed by #70), debris, particles, max_debris, max_particles, break_budget_ms; presets low / medium / high (default high), any edit → custom; --set key=value,… overrides one run without saving; reproducible runs (--screenshot, --headless) ignore the user's file and never write it. Settings window on key O (debug-styled, applies live, saves). Details: docs/settings.md.
  39. #66
    Destructible statics (user directive: hits shake the sprite with a shader displacement and an old-school white flash, damage shows as cracks in albedo and normals, a destroyed piece is replaced by a 3D model that falls apart and sinks into the ground): uo_proto::objects::durability(sprite) = hits to break (stone walls 8, other walls 5, doors/gates/fences 4–6, trees/rocks 6, else 3; ground, floors, roofs, stairs, docks, rugs indestructible). Per-object effects in the shared impostor material use effect slots: MeshTag = frame record | slot << 20, 4096 ImpostorFx { hit_time, damage, seed, push } in a fixed-size storage buffer written only on hits; the shader animates from globals.time: flat HDR-white flash 0.07 s, whole-texel per-row sideways shake 0.35 s pinned at the ground, cracks = borders of a seeded 3D Voronoi tiling (CRACK_CELL 0.5) appearing by per-border threshold as damage grows, one screen texel wide (width from the surface's texel step), darkened with bent normals, cells tilting apart past 40 %. The last hit replaces the impostor with chunks built from the sprite: each covered texel a prism along the view ray from height to height_back, cut into 2-texel slices that join the same Voronoi cells (bit-exact hash in Rust and WGSL), so it splits along its cracks and in depth; cells whose debris would exceed MAX_CHUNK 0.7 units are cut again by finer cells (0.3), so big volumes break into many small chunks (user feedback); vanishing parts: materials with custom property uo_debris = "vanish" (object_kit.vanish: leaves, flames, embers) are baked into the optional debris.png mask (has_debris; soft plant matter too: flowers, grass, reeds, lily pads, vines) and burst into particles on breaking (≤ 400 texel-sized boxes in the texel's colour: leaves/petals flutter down and fade, emissive texels fly up as sparks; toon mesh without outline, sparks with vertex emission), as do glowing texels; toon-mesh chunks with vertex colours fly, bounce, rest and sink after 2.5–4 s; broken statics unblock their edges/tile. Area destruction: a Blast { center, radius, damage } hits every destructible static in range (damage falling off with distance², chunks thrown from the centre); breaks go through a BreakQueue processed within a per-frame CPU budget, each object drawn until its debris replaces it. Debris/particle amounts follow the user settings (#67): debris physics / fall (crack-cell chunks dropping straight down, no bounce) / off, particles full / reduced / off, alive caps (oldest chunks sink early). Client-side for now (offline right-click, Shift + right-click blast; --destruction-test, UO_DESTRUCTION_BLAST); server-owned object damage and wire events are future work. Details: docs/destruction.md.
  40. #65
    Simplified shadow shells (amends #23/#31): every texel-grid shadow shell is decimated by meshoptimizer edge collapses within an absolute error of SHELL_ERROR = 0.01 units (≈ 0.2 sprite texels, ≈ half a sun-shadow-map texel at the default view, below the depth bias); vertices stay a subset of the grid shell. Village shells 2.16 M → 0.53 M triangles; shadows differ only in single texels along edges (0.16–0.28 % of texels at 7, 13, 17 h; accepted by the user). Cached static shadow maps not needed. UO_FREEZE_TIME=1 freezes game time so --screenshot captures are identical between runs (use it for image comparisons). Perf methodology addendum to #28/#49: flat-out headless runs faster than ≈ 10 ms throttle (M1 power/thermal governor) after 5–10 s, so check a frame-rate target by pacing just under it with UO_FRAME_CAP_MS and seeing whether the frame time holds; the village holds 8.2 ms at a 7-ms pace. Numbers: docs/performance.md › Simplified shadow shells.
  41. #64
    Impostor sprite atlas (implements the atlas deferred in #29/#33): every sprite frame, trimmed to its covered texels, is shelf-packed into five 2D texture arrays (1024² layers, same formats as before, height stays R32Float, textureLoad only) and described by a FrameRecord (atlas texel + layer, size, offset from the anchor, height_range, emissive_scale) in a storage buffer. All impostors use one ImpostorMaterial and one unit-quad mesh; the entity's MeshTag selects the record, a custom vertex shader sizes the quad, and the culling Aabb is set per entity (NoAutoAabb). Lazy loading keeps working: only new rects are written (render-world write_texture; images have no CPU data), full arrays double their layers (copy_on_resize) and the material is re-prepared in the two frames after a growth. Images unchanged (debug views pixel-identical). Village: −4.5 ms with sun shadows off (9.9 → 5.2 ms), but this exposes ≈ 6 ms of shadow-shell rendering that main's per-material cost was hiding, so the default view is 12.0 vs 10.75 ms; next step is shell cost (LOD or cached static shadows; lossless planar merging gives < 1 %). Code: uo-client/src/atlas.rs; details: docs/rendering.md › Sprite atlas, docs/performance.md › Impostor atlas.
  42. #63
    Time-of-day looks (night look superseded by #95: moonlit blue, warm white lights) (user directive: night black with only artificial lights, golden evening, pink morning; extends #62, amends #20/#23's moon light and dusk sky): sun colour/strength, sky ambient and camera grading are look::Phases (night, morning, day, golden, dusk) blended linearly by hour along look::SCHEDULE (night → 5:00, pink morning 6:24, day 8:30–16:12, golden 17:36, dusk 18:24, night from 19:00). Night has no sun/moon and no sky ambient; a black toon band below TOON_T0 keeps unlit texels black, so only torches, braziers, forges, lanterns and windows light it. The sun keeps its own direction until well below the horizon (faded out by then), so the moon switch is never seen. Grading is rewritten only when the phase changes (#32). The debug mouse-cursor light is now off by default (L). Phases are overridable via UO_LOOK (golden.sun=…, night.sky_strength=…).
  43. #62
    Calm, warm look (user directive with a paint-over: lower overall contrast, calmer palette, softer and lighter shadows, warm post-processing; amends #38/#40's hard dark banding and blue shadow tint): all look values live in uo-client/src/look.rs (Look, UO_LOOK="k=v,…" overrides without rebuild; shader values substituted into common.wgsl at startup). Baked albedo is calmed in every material shader (calm_albedo: sRGB contrast 0.9, small lift) instead of rebaking; toon bands lifted (deepest 0.2 → 0.92 lit), warm sun / cool sky ambient for warm lights and light cool shadows, soft (Gaussian) sun shadows banded into steps, AO step 0.88, outline darken 0.35; camera AcesFitted + ColorGrading (+0.75 EV, slight warmth, post saturation 1.15). Tuned against the paint-over by frame statistics (mean luminance, saturation, warmth, grass lit/shadow colours all within a few %). Details: docs/rendering.md › Look.
  44. #61
    Wood floor joints are 1-pixel lit grooves (user feedback on #60: joints too contrasty and 2 px wide): a joint 1 texel wide in plan draws 2 px tall along X/Y, so wood floors (floor_wood, floor_wood_light, floor_wood_dark; rsf_kit.plank_floor) cut half-texel grooves one level deep between plank boxes instead of painting near-black texels; the groove shows the plank's side face (albedo ×0.75), so PBR/toon lighting shades the joint. Outer sides on the tile outline keep the plank colour (a dark rim bled through neighbouring tiles at equal depth). One butt joint per row (long boards, not a grid of short ones); nails softened. Half-texel joints put groove corners off the lattice: the plank mesh is free().
  45. #60
    Whole catalog on the texel lattice (user directive: make the game crisper; extends #54): every object model (≈ 480 sprites: walls, roofs/stairs/floors, doors/fences, furniture, decor, civic, lights/crafting, misc, items, nature) is rebuilt with pixel_kit so every vertex sits on the lattice (X/Y whole texels, Z on levels) and bakes with resolve = dominant, now the default (ground tiles keep mean). Rules learnt: only verticals and 1:1 screen lines are fully clean (flat screen lines and plan-(1,1) outlines sit on half-pixel rows), slants rise 2 levels per texel towards the front, no sub-texel bevels, recessed one-texel details hide (joints/glass are flush colour), small round parts are box stacks, organic shapes snap_mesh or one-texel voxels; every script ends with check_lattice(). Shared architecture numbers (pixel_kit; amends #16, #22, #51, #53): storey = WALL_HEIGHT = 48 px (≈ 2.12), walls 2 texels thick (wall face Y = 6, furniture backs ≤ 5), doorway 12 × 42, window 8 × 18 at levels 20..38, floor top level 1, stair tile rise 12 px, table/counter/nightstand tops 17/22/18 px, ROOF_COURSE = 32 px (55° pitch; measured cleaner than 16 px; roof.py takes rise). Client: iso::TEXEL_Z / snap_z, statics snap their height to whole pixels when spawned, village placement constants in pixels. Result: mixed texels ≈ 50 % → 8–30 %, near-threshold edges ≈ 60 % → 2–20 % per group (nature, lathes and camera-facing diagonal pieces stay softest). Details: docs/asset-pipeline.md › Texel lattice, Pixel-crisp assets.
  46. #59
    Final UI concept (user choice, refines #56): the GPT Image 2.5 mockups that merge the EVE-style playing HUD (icon rail, selected card, locked-target portrait cards, overview table, segmented vitals gauge, abilities, chat with local list) with dense data windows (market order book, backpack tree-table, wallet journal, industry queue) in one design system: smoked-glass panels, brass hairlines and corner brackets, rune or illuminated-initial titles, monospace tabular data, colour only as signal. Two layouts kept: docked columns and bottom dock. docs/ui-concepts/ holds only the two final images, their prompts and a design-system summary; exploration rounds were deleted. No UI framework chosen yet.
  47. #58
    Bare body parts replace underclothes: import_quaternius.py (cut_base_body) cuts each Universal Base Characters body into regions by bone weight, one region per triangle (argmax of the summed per-vertex weights): head (Head, neck_01), body (spine_*, clavicles), legs (pelvis, thighs; the texture's briefs), calves, feet, arms (incl. hands). Seamless rule: import with merged vertices (glTF splits UV seams), assign triangles, then decimate each region alone with region-border vertices and their triangles locked (abort if a border vertex moved), so pieces share border vertices exactly; heads are regenerated from the same cut (the old cut decimated after cutting and cracked along UV seams). Pieces: heads/<g>_Head and bare/<g>_{Body, Legs (+calves), Feet, Feet_Calves, Arms}; calves come with bare feet under clothed legs (Feet_Calves: outfit shoes cover the calf, trousers end above the ankle). Client: CharacterAppearance body/legs/feet/arms None = bare skin (no more hole/underclothes), material = base skin (MatKind::BaseSkin, Skin light/dark like the head); paperdoll.rs leaves empty regions None. --lineup gains two seam-check rows. Details: docs/characters.md.
  48. #57
    Worn items drive the character look (client): a mobile with a CharacterSheet (uo_proto Character) and a BodyLook (gender, skin, hair, beard) gets its CharacterAppearance derived from its equipment (uo-client/src/paperdoll.rs), re-derived on change with set_if_neq. Appearance slots hold a per-part Part { outfit, palette, dye } (the per-character palette field is gone). UO slots collapse onto the six pack regions, outermost layer wins (body: robe > tunic > chest armor > shirt; legs: skirt > leg armor > pants; arms: sleeves > gloves; head → hood, else hair; cloak → pauldron). Empty body/legs/feet/arms regions wear underclothes (dyed Peasant part), because the pack has no bare limbs. Superseded by #58: empty regions show bare skin. item_part is placeholder art, and every catalog item in a drawn slot must have an entry (tested). A changed appearance re-assembles the character, with the old look shown until the new one is merged. Offline the demo player is built with NewCharacter::build and starter items. Debug windows (key C, --character-windows) show paperdoll, backpack tree with weights, and stats/skills with modifier breakdowns, with equip/unequip and test-effect buttons. bevy default_font feature added. No server code or wire commands. Details: docs/characters.md › Items → appearance.
  49. #56
    UI direction (user directive): very minimalist but still fantasy, and EVE Online-style: dense, spreadsheet-like windows (sortable tables, overview list, target brackets, locked-target cards, capacitor-like vital rings, docked multi-window layouts) for players who like numbers. Fantasy only through typography (serif small caps), hairline brass/gold accents, runes/illuminated initials; colour only as signal (notoriety, vitals, price moves). First step: AI mockups over a real client screenshot with Nano Banana 2, Nano Banana Pro and GPT Image 2.5 via kie.ai; prompts and results in docs/ui-concepts/ (key never committed). No UI framework or layout is chosen yet.
  50. #55
    On-chain combat = tempo timeline (user directive; amends #7 and the "combat is server work" part of #52; game-time ticks, strict turns, no positions, turn deadlines superseded by #68: real time on the world grid): every fight, PvE and PvP, is resolved by uo_ledger. Turn order is a public FFX-style timeline: each action costs game time (UO weapon speed, spell cast time), the lowest next_at acts, so turns are sequential with no commit–reveal; casts resolve on the caster's next turn and can be fizzled by damage; a missed turn deadline lets anyone crank an auto-attack (UO auto-swing + anti-stall). Players sign act with owner/session key; the server plays mobs, signing their actions as game authority — mob AI stays off chain and free-form, the program enforces the rules. Shared no_std resolver for program/server/SDK; spells burn on-chain reagents. Open: RNG source (VRF recommended), ephemeral rollup vs L1, death/loot, skills on chain. Design only, not implemented: docs/combat.md.
  51. #54
    Pixel-crisp assets: texel lattice + dominant-surface bake (user directive: assets must be very crisp; experiment on two assets, adoption for the catalog pending). The baker's origin is a texel corner, so a model is crisp when every vertex projects to (integer, integer + ½) texels: X, Y multiples of 1/16 tile, Z = (n + ½)/(16√2) ("level n"). Vertical edges then lie on texel boundaries and X/Y edges cut texels 1/8 : 7/8 instead of through the centre (coin flip at the 50 % coverage threshold); the lattice survives the 90° facing turns. Slants use pixel-art slopes (2 levels per texel = 1:1 on screen, rising towards the front); no sub-texel bevels. Helpers + check_lattice(): tools/blender/models/pixel_kit.py; examples tools/blender/models/pixel/ (crate, bookcase; since #60 crate.py and furniture/storage.py). bake_sprite.py --resolve dominant takes each texel's dominant surface (medoid subsample) instead of the mean (default stays mean (dominant since #60)); every frame logs mixed texels and near-threshold edge texels. uo-baker exposes it as --resolve mean|dominant and per asset as manifest resolve = "dominant" (overrides the flag), and prints a crispness line per asset (mean/max of both stats over its frames); bake-all ends with a least-crisp-first summary so regressions stand out. Result: near-threshold edges ≈ 50 % → 0 %, mixed texels 10–42 % → 0–8 %. Details: docs/asset-pipeline.md › Pixel-crisp assets.
  52. #53
    Demo village (user directive: showcase all objects, several houses with different wall sets, some more than one storey): uo-client/src/village.rs is a pure plan (statics, blocked tiles, terrain paint) of a 90 x 56 village on a 128 x 128 map that places every catalog object — a test checks it against assets/manifest.d (add new catalog assets to the village). Buildings are data (Building: wall set, size, storeys, roof, floors, doors, stair flights, items); storey s at z + 2.2 s (now WALL_HEIGHT = 48 px, #60), its floor skips the stairwell of the flight below; a flight = 4 step_s/step_e tiles rising 0.55 (now 12 px, #60) with stairs_*_block underneath; windows on wall tiles at offset 2 mod 3; fences reuse the wall ring with gates. Upper storeys and roofs carry Overhead { area, level, roof }; RoofView (R cycles (H since #96), --roofs auto|no-roofs|ground-floor, replaces --hide-roofs): auto hides a building's overhead while the player is inside it, no-roofs hides all roofs, ground-floor shows only ground floors; shells stay visible. Frames without solid texels get no shadow shell (empty meshes break the mesh allocator). Perf: ≈ 11.5 ms at the default view vs 6.5 on main — cost tracks the number of different sprites (materials) in view, not fill, shadows or lights; the atlas/single-material impostor (#29/#33) is the fix. Details: docs/uo-objects.md › Demo village, docs/performance.md › Demo village.
  53. #52
    Character system base architecture (user directive: flexible character with equipment, inventory, skills; no server, no skill mechanics yet). Model in uo_proto::character, shared by server/client/SDK/MCP; data + invariants only, mechanics (skill use/gain, combat (moved on chain, see #55), regen, timers) are server work. Only base values are stored; effective values = base + Modifiers ((base + Σ add) · (100 + Σ %) / 100) from worn items (catalog ItemDef::mods + instance props) and keyed Effects (same key replaces), computed on demand, never cached. Skill = UO Renaissance's 49 skills, discriminant = UO skill number, append-only, values in tenths (1000 = 100.0), sparse Skills, Lock up/down/locked for skills and stats. Ruleset is serializable data (caps, linear vital/carry formulas, creation limits), default RENAISSANCE (225/100 stats, 700.0/100.0 skills, hits = Str, 40 + 3.5·Str stones, creation 80 stat points + ≤ 3 skills summing 100.0). ItemDef is a template of optional components (weight, stackable, equip {slot, two_handed, requires}, weapon, armor, container, tool, mods), built with const builder methods; new id ranges 200.. containers, 300.. weapons, 350.. shields, 400.. armor, 500.. clothing, 550.. jewelry. EquipSlot = UO layers (append-only, the old four first); two-handed items sit in main_hand and block off_hand. Inventory = Possessions, a flat item tree uid -> {item, place} (place = slot or container uid), backpack = the item in the backpack slot; server-allocated ItemUids; structural rules (slot match, two-handed, no cycles, nested container limits 125 items / 400 stones) enforced by every operation, requirements by Character::equip against effective values. Ledger boundary: chain owns counts, Possessions::totals() must match it. Wire commands and client paperdoll mapping are future work. Details: docs/character-system.md.
  54. #51
    Static object layer (world objects in the client): uo_proto::objects::StaticObject { sprite, x, y, z, facing } — origin at the tile centre, z absolute (world units), multi-tile _s/_e assets on their north-west tile, wall/fence/door pieces on the tile whose north (_x) / west (_y) edge they stand on. Facing (N, E, S, W) → frame: 4-frame sheets N, E, S, W; 8-frame sheets Direction order; variants > 1 → per-tile hash. Roofs: hip_roof() picks each tile's piece from its nearest outer edges, eaves at WALL_HEIGHT 2.2, +ROOF_COURSE 1.0 per tile inwards (now 48 px and 32 px, #60) (keep the short side even); roofs hide while the player is inside (UO) or with R / --hide-roofs, keeping their shadow shells (roof hiding superseded by #53: Overhead, --roofs). Edge blocking from names (blocked_edges). Client: Static component → SpriteView; sprites outside SPRITE_NAMES load on first use (SpriteLoader). Lights from emissive texels: glowing texels (the ones excluded from shadow shells) give a per-frame SpriteLight (mean 3D position, colour); statics get a child PointLight (2500·sqrt(texels·strength) lm, 5k–90k, no shadow maps, warm ones flicker); walls excluded (glowing windows). Demo: furnished stone house + yard built from statics. Details: docs/rendering.md › Static objects.
  55. #50
    UO Renaissance objects (user directive; goal: all Renaissance-era world objects as sprites): catalog in docs/uo-objects.md (walls sets, doors/fences, furniture, lights/crafting, decoration, civic/dungeon, nature, items; ≈ 400 sprites), modelled by parallel agents per group from docs/object-brief.md, developed on feat/uo-objects. Baker: directions = 4 (frames N, E, S, W; UO-style per-facing art; model built facing south, wall-mounted items against the north edge so South = on an x wall, East = on a y wall), manifest params → env UO_PARAM_<KEY> (one script, many assets), group manifests assets/manifest.d/*.toml (paths relative to their file). Scripts in tools/blender/models/<group>/ on object_kit.py (shared PALETTE + primitives); multi-tile objects have their origin at the centre of the north-west-most tile. Review sheets: tools/gallery.py.
  56. #49
    Textured terrain cost measured: one ground-tile lookup (worst case, Wang terrain) ≈ 0.17–0.19 ms per frame at 2560×1600 zoom 4; all defaults 5.6 ms, still within #39's 5.3–5.7 ms, so no optimisation. Perf methodology addendum to #28/#32: when the machine can't be made quiet (other apps using the GPU), rotate the compared binaries in short runs and compare p10/minimum, and isolate a shader's cost by running it N times in a throwaway bench build; single A/B runs drifted by 2–3×. Details: docs/performance.md › Textured terrain.
  57. #48
    Characters = Quaternius CC0 packs (user directive; credit "models by Quaternius"): Modular Character Outfits – Fantasy (outfit parts), Universal Base Characters (heads cut from the base bodies to Head/neck_01 vertices + eyes/brows, hairstyles rigged to the head bone), Universal Animation Library 1+2 (31 curated in-place clips; locomotion speeds measured from the root-motion variants). Converted by tools/blender/import_quaternius.py into assets/models/quaternius/ (7.6 MB; glTF + shared 256²/128² textures sampled nearest; no normal maps/vertex colours; meshes collapse-decimated above 1100 tris; skeleton untouched; originals never committed). Client: CharacterAppearance { gender, skin, body, legs, feet, arms, head (hair|hood), beard, hair_color, acc, palette }; at assembly all parts are merged into one skinned mesh per material on the body's skeleton (parts with a different rest pose keep their own bind poses); one shared AnimationGraph, Activity → clip, locomotion clip + playback rate from ground speed (no foot sliding), 0.15 s crossfades. Readability: CharacterLook (sRGB dark lift, value posterise with hue kept, light floor, hard rim, metallic-only glints), darker character outline (prepass tag 2/3), Ranger defaults to its brown colourway, grey hair textures tinted per character. Items/EquipSlot → slots and server events → Activity are future work; protocol unchanged. Details: docs/characters.md. Bare body parts cut the same way: #55.
  58. #47
    Terrain shader supports edge-matched (Wang) ground variants (#46): ground layers are laid out per terrain (TerrainInfo table in the terrain uniform: first layer, variant count, Wang per_combo/p) instead of a fixed count per terrain, so swamp's 64 and forest's 16 variants cost only their own layers (176 total, under wgpu's 256). Variant pick = uo-baker's tiles::pick_variant with seed 0, bit-exact in WGSL: plain sheets hash the tile, Wang sheets pick the variant matching the four hashed edge types. Tests check Wang neighbours agree on every shared edge and no holes appear for any mix.
  59. #46
    Edge-matched ground variants (user feedback: swamp puddles repeated per tile, forest leaves clustered in tile centres): ground tiles may be stochastic Wang tiles — manifest wang_p / wang_per_combo → sprite.json "wang": {p, per_combo}, variants = 16 * per_combo. Every map edge gets a type bit (hash(edge) < p) and a cell uses variant = combo * per_combo + hash(cell) % per_combo with combo = n | e<<1 | s<<2 | w<<3 (rule in docs/sprite-format.md; the client must pick variants this way). Edge features are built from edge_rng in edge coordinates so both tiles of an edge build the same feature; objects straddling an edge are cut with clip_to_tile; features stay CORNER away from tile corners. Swamp: wet edges p = 0.15, 4 interiors/combo (≈ half of tiles dry, water chains across tiles); forest: two leaf layouts per edge, p = 0.5. tile-check compares each edge only between variants of the same edge type.
  60. #45
    Terrain is textured with the baked ground tiles (#42). uo_proto::map::Terrain extended append-only to the 14 UO base types: id = discriminant = texture layer, Terrain::ALL, name() = serde name, sprite ground_<name>; water, rock and lava are not walkable. Map generation adds swamp on wet shores, forest/jungle in wet lowlands, furrow plots on dry lowlands, cave floor, lava vents and snow caps in the mountains; cobblestones/flagstone come from settlements (the demo yard), not noise. The client uploads every ground variant frame into texture arrays (shared canvas/anchor; layer layout superseded by #47); the terrain shader textureLoads at the world-anchored screen texel: the owning screen-lattice tile picks the variant by integer hash, and the frontmost covered copy among owner + 3 front neighbours wins (relief overlap), so baked texels map 1:1 to screen texels (no resampling, #40) exactly as if the variant sprites were drawn side by side. Borders between types stay the dithered per-texel pick. The water sheet takes only the water tile's colour (whole-texel drift) and keeps procedural ripple normals. Details: docs/rendering.md › Terrain tiles.
  61. #44
    3D mesh mobiles (user directive): players/NPCs can be glTF (.glb) meshes in the same scene as impostor sprites (--player-mesh mesh|sprite, default mesh; 8-direction snapping by default, --smooth-turn optional). Models live in assets/models/, exported from the procedural Blender scripts by tools/blender/export_glb.py (glTF Y-up = client Bevy space; origin at feet, 1 unit = 1 tile, front −Y). Their materials are swapped at load for ToonMeshMaterial (same toon banding, object tag in the normal prepass → same sel-out outline); meshes are regular shadow casters/receivers, so shadows and per-pixel occlusion work both ways with impostors. Mesh mobiles keep a SpriteView for position/facing so walking and network interpolation are shared. Skinned animation is not wired yet (no rigged model chosen).
  62. #43
    Ground tile variants (user directive: 5–10 per tile, mixed, no visible seams): every ground_* is baked as 8 variants in one sheet (variants = 8 in the manifest; the model script runs with UO_VARIANT=0..7; sprite.json gets "variants", frame i = variant i; static sprites only). Seamless rule: a shared, periodic edge band (ground_kit.EDGE = 0.12) identical in all variants + a zero-mean per-variant interior faded in with edge_window (value and slope 0 at the edge); colour from periodic vertex colours (no Blender 3D noise); the tile is an open surface sheet — no slab or side walls, whose sides outline every tile. uo-baker tile-check verifies edge agreement and renders a random-mix mosaic. The client picks a variant per cell deterministically (hash of cell coords).
  63. #42
    UO base terrain set (user directive): one baked 1×1 ground sprite per UO Renaissance land terrain type — grass, forest, jungle, swamp, dirt, furrows, sand, snow, rock, cave, lava, water, cobblestones, flagstone (+ floor_wood); named ground_<type>, list and per-tile checklist in docs/uo-base-tiles.md. One variant per type for now; texture variants and terrain transitions are later work (transitions likely blended in the terrain shader, not baked). void has no sprite. Ground tiles must tile seamlessly (periodic heights, edge grout split half/half).
  64. #41
    Git workflow (user directive): one separate commit per feature / logical change (no mixed commits); big features are developed in a git worktree on their own branch and merged when done. See Git workflow below.
  65. #40
    Pixel-art consistency rules: nothing may be smooth below texel rate. Terrain picks one tile type per texel with a 1–2 texel world-anchored Bayer 4×4 dither band at borders; in-type noise posterised. Bloom only above HDR 1.0 (emissive), additive. In toon mode SSAO is a single hard step (×0.75) and shadows use hard filtering. Strongly emissive texels (flames) don't cast shadows. Outline luminance is capped at 55 % of the pixel it sits on (hue kept).
  66. #39
    Stylized pipeline implemented: outline pass in Core3dSystems::EarlyPostProcess on the HDR frame (constants in uo-client/src/outline.rs; sprites tagged via normal-prepass alpha 1/3, terrain 1); toon constants TOON_* in shaders/common.wgsl; WGSL PX substituted from iso::PX_PER_UNIT at startup (no hardcoded densities in shaders). Perf at 2560×1600 zoom 4: 5.3–5.7 ms (175–190 fps) all on, 2.9 ms all off; outline ≈ 0.2 ms, toon ≈ 0.3 ms.
  67. #38
    Toon / quantized lighting (user directive; band values, shadow tint and shadow filtering softened by #62): incoming light intensity per texel is quantized into hard bands (default 4) after PBR light accumulation, keeping light hue; point-light pools become stepped rings; hard specular threshold; hard-edged shadows; darkest band tinted cool (pixel-art shadow ramp). Emissive is added after quantization. Toggle key 8 (removed by #70: settings window) / --disable toon.
  68. #37
    Pixel-art bake: every pass rendered at 4× supersample (--supersample), downsampled per texel — alpha = coverage ≥ 50 % (hard edges), albedo/material/emissive/normal averaged over covered subsamples only, front height = max, back height = min. uo-baker passes --tile-px from uo_proto::iso::TILE_W (no hardcoded tile size in Python). Modelling rule: think in texels (1 texel ≈ 0.045 units at 32 px tiles).
  69. #36
    height_back is baked by rendering from the opposite direction (user directive; supersedes peeling in #27 as the default): a second ortho camera looking along +(1,1,√2) with the same scale/stretch, so its pixel rays are the same lines; the first hit from that side = the last exit along the ray. Robust (no transparency, no manifold requirement); trade-off: fills air gaps between separate parts lying on the same ray. Peeling kept as --back-mode peel.
  70. #35
    Outlines (user directive): post-process at texel resolution, 1-texel outline outside object silhouettes from view-depth jumps, coloured as a darkened version of the adjacent object's lit colour ("sel-out"). Sprites only (terrain excluded); sprite-vs-sprite overlaps are outlined. Toggle key 7 (removed by #70: settings window) / --disable outline.
  71. #34
    Stylized pixel art (user directive): texel density halved — tile = 32×32 diamond, PX_PER_UNIT = 32/√2 ≈ 22.63 (supersedes the 64 px tile in #14; projection otherwise unchanged). Scene renders at texel resolution and is upscaled by an integer zoom (default 4, range 2–8) with nearest filtering, so lighting/shadows/bloom are also per chunky texel. Sprites are baked supersampled and downsampled pixel-art-aware (hard alpha, covered-only averaging). Small details must be modelled chunky enough to survive 32 px tiles.
  72. #33
    Scaling (--stress N): +1k sprites 10.1 ms, +5k 19.5 ms with sun shadows (9.6 without) — sun-shadow shells scale worst. Keep visible sprites < ~2k until shell LOD/decimation, a shadow-caster cap and atlas/instancing land (next performance phase).
  73. #32
    Perf rule: never mutate a resource or asset every frame unless it actually changed (use set_if_neq / compare before writing) — a per-frame Toggles write re-prepared every material and was the whole ≈ 4 ms floor. Benchmarks run in --headless (offscreen, unthrottled; windowed macOS runs cap at 60 Hz even with --no-vsync) with UO_FRAME_STATS=1; --perf (render GPU counters) does not work headless.
  74. #31
    Performance targets met on M1 at 2560×1600 zoom 2: ≈ 8.0 ms (126 fps) with default effects, 4.4 ms (225 fps) without. Texel-resolution render (48-texel guard band) + nearest upscale via an unlit quad on render layer 2, whole-texel camera snap with sub-texel offset of the quad for smooth scrolling. SSAO quality Low, sun shadow map 2048, shadow shells at 2 (small) / 4 (big) texels, point-light (torch/brazier) shadows off by default (key 6). Effect costs: sun shadows ≈ 1.7 ms, SSAO ≈ 0.9, bloom ≈ 1.0. Table: docs/performance.md.
  75. #30
    Dev profile: debug = "line-tables-only", dependencies opt-level = 2, debug = false (keeps target/debug small; the 16 GB debug build filled the disk).
  76. #29
    The 3D scene renders at texel resolution (window ÷ zoom + guard band) and is upscaled by the integer zoom with nearest filtering; all screen-space effects run at texel resolution. Targets: ≥ 120 fps with default effects, ≥ 200 fps without, at 2560×1600 zoom 2 on an M1. Atlas + single instanced draw for impostors is deferred until a --stress scene shows draw-call cost matters.
  77. #28
    Performance methodology: measure only --release with --perf --no-vsync on a quiet machine (check uptime; parallel builds made numbers 2–4× worse). Baseline (M1, 2560×1600, zoom 2): 40.7 ms all effects; SSAO ≈ 13 ms, bloom ≈ 13 ms, shadows ≈ 3.6 ms; fixed floor ≈ 8 ms. Draw calls are not the bottleneck (render CPU per pass < 0.25 ms); fill rate is.
  78. #27
    Default superseded by #36. height_back is baked by depth peeling (up to 16 Cycles layers using Light Path › Transparent Depth) plus an inside/outside counter in numpy: +1 front face, −1 back face, exit = the back face that brings it to 0. Overlapping closed parts count as one solid. Modelling rule: closed (manifold) meshes, overlap parts instead of leaving faces touching; open meshes get back = front. Full bake-all ≈ 75 s.
  79. #26
    Off-screen casters must cast shadows (user directive): screen-space effects render with a guard band (overscan ≥ max shadow length on screen) and only the inner part is shown; shadow-map bounds cover the visible ground expanded by the max shadow length toward the sun, and shadow shells are never culled by camera visibility.
  80. #25
    Sprite format v3 adds height_back.png (user proposal): 16-bit, same scale as height.png, the z where each pixel's projection ray exits the object (its back side). Because rays run along (1,1,√2), z alone fixes the 3D point, so each pixel knows the solid interval [back, front] (back_z ≤ front_z). Used for closed shadow shells (thin parts cast thin shadows) and thickness-aware screen-space shadows. Baked in Cycles: front faces transparent, back faces emit height.
  81. #24
    v2 material (metallic/roughness/specular) and emissive maps feed Bevy PBR per pixel; emission drives bloom.
  82. #23
    Shadows: Bevy directional shadow maps fed by auto-generated shadow shells: per sprite frame, the height map is lifted back to true 3D positions (2-px grid) to make a mesh on RenderLayers 1, which only lights see; impostor quads are NotShadowCaster. Bevy ContactShadows (screen-space, on the frag_depth-correct depth buffer) are added on top. Works automatically for every baked sprite. Sun: noon azimuth 150° (south-west), elevation 42°, the daily arc stays ≥ 60° off the view axis (constants in uo-client/src/lighting.rs).
  83. #22
    Walls sit inside their tile, flush with the north (wall_x) / west (wall_y) edge, origin at the tile centre; place them on the tile of their origin. Floor plank tops at z = 0.05 (now level 1, #60). Renderers must renormalise sampled normals.
  84. #21
    Baker: Blender headless + tools/blender/bake_sprite.py, five Cycles emission passes via material swaps (no compositor, API-stable), linear EXR → numpy → PNG. Oblique stretch via render pixel_aspect_x = √2; baker self-checks the camera against iso.rs (abort if error > 0.05 px). Assets listed in assets/manifest.toml; cargo run -p uo-baker -- bake-all --preview. Relief must be modelled as geometry (normal/bump nodes and alpha cut-outs are not baked). Works with Blender 4.2 and 5.2 (installed: 5.2.2).
  85. #20
    Sun must light the scene from the side, never along the camera axis (projection direction (1,1,√2)), so shadows are visible (user directive). Day–night arc stays off the view axis. Shadows from impostors cannot come from rasterising camera-facing quads in the light view; technique: see #23.
  86. #19
    Client on Bevy 0.19.1 (0.20 needs rustc ≥ 1.97.1, not installed yet). Sprites are rendered as impostors: camera-facing quads in Bevy's 3D world whose shader rebuilds each texel's world position from the height map and writes frag_depth, so Bevy PBR lighting, shadows and post-processing apply per pixel and sprites occlude each other per pixel. Camera: orthographic Camera3d at 45° with a fixed non-uniform ortho area (√2 vertical stretch = oblique projection). Terrain is a real 3D mesh.
  87. #18
    Sprite format v2 (user directive): besides albedo/normal/height every sprite has material.png (RGBA8 linear: R metallic, G roughness, B specular level, A coverage) and emissive.png (RGBA8 sRGB, scaled by emissive_scale in sprite.json) so objects can be metallic/shiny and self-illuminated. Baker reads them from Principled BSDF inputs; v1 sprites load with defaults.
  88. #17
    The game server is the fee payer for player transactions (craft, transfer, set_delegate): the player/session key signs, the server pays. Players never need SOL. Covered by LiteSVM tests in chain/tests.
  89. #16
    Wall convention (UO-style): one wall piece per tile, standing on the tile's north edge (wall_x, runs along x) or west edge (wall_y, runs along y); wall_corner joins both at the north-west corner, wall_post is the corner pillar; window/door/arch variants. Wall height 2.2 units, thickness ≈0.15 (now 48 px ≈ 2.12 and 2 texels, #60).
  90. #15
    Current priority (user directive): a runnable Bevy client ASAP with the camera set up and test sprites rendered through the target pipeline (albedo + normal + height, lit per pixel). Rough placeholder art is acceptable. Test set: ground tiles, wooden floor tile, UO-housing-style wall pieces (reference: UO Mondain's Legacy elvish housing sets) plus character/tree/ore/crate.
  91. #14
    Oblique (shear) projection, as in original UO (user directive): ground and vertical planes are both drawn 1:1, no foreshortening. Tile = 64×64 px diamond (now 32×32, see #34); sx = (x−y)·32, sy = (x+y)·32 − z·Z_PX, Z_PX = PX_PER_UNIT = 64/√2 ≈ 45.25. Implemented everywhere via the exact equivalence orthographic camera at 45° elevation looking north-west (Blender rotation (45°, 0, 45°)) + image stretched vertically by √2 — geometry stays truly 3D, so lighting stays correct. Depth (closer = bigger): (x+y)/2 + z/√2. Game space still x east, y south, z up; game = (X, −Y, Z) from Blender. (UO used 44 px tiles; we use 64 for modern resolutions.)
  92. #13
    This file is the decision log; updating it with every new decision/directive is mandatory.
  93. #12
    Client engine: Bevy (user directive; supersedes the initial raw winit+wgpu plan). Sprites are rendered so that Bevy's lighting/post-processing work per pixel using the normal and height maps (details in docs/rendering.md).
  94. #11
    Dev mode: server keeps custodial dev wallets per character name under .data/; an in-memory ledger mirrors program rules for chain-less development. Dev program keypair is committed at chain/keys/ (devnet/localnet only — never mainnet).
  95. #10
    chain/ is a separate Cargo workspace (Solana platform-tools ship an older cargo/rustc; avoids dependency clashes). Lockfile resolved MSRV-aware (resolver.incompatible-rust-versions="fallback", rust-version 1.84). Program tests use LiteSVM in-process. The off-chain server talks to the program by building Anchor instructions by hand (discriminator + borsh) — no anchor-lang dependency off-chain.
  96. #9
    v0 inventories are fungible stacks inside a per-player Character PDA (["character", owner], 32 slots). Item/recipe ids are shared with uo-proto::items and are append-only. Migration to SPL/Token-2022 or compressed NFTs for tradable/unique items is a later decision.
  97. #8
    gather is signed by the server's game authority key (the server attests proximity/cooldowns); craft/transfer_item are signed by the character owner or its delegated session key (for clients and LLM agents). Server pays rent for new characters so players need no SOL.
  98. #7
    Chain boundary: centralized server owns real-time/cosmetic rules (movement, equipping, chat, combat — moved on chain, see #55). Solana (uo_ledger, Anchor 0.32.1) owns the economy: player-owned inventories, resource gathering, crafting, item transfers.
  99. #6
    Characters/mobiles bake 8 directions (Direction::ALL order, North first); static objects bake 1. Model front faces Blender −Y. 1 Blender unit = 1 tile.
  100. #5
    Superseded by #14. Projection: 2:1 dimetric, camera 30° elevation looking north-west, tile = 64×32 px diamond, PX_PER_UNIT = 64/√2. Game space is UO-style left-handed: x east, y south, z up; Blender ↔ game: game = (X, -Y, Z). Single source of truth: crates/uo-proto/src/iso.rs — changing it means re-baking all sprites.
  101. #4
    Asset pipeline: model in Blender via Blender MCP (from primitives / rough models) → uo-baker drives Blender headless, places the model at the iso angle, sets an orthographic camera and renders albedo/normal/height to textures. Contract: docs/sprite-format.md.
  102. #3
    Automation is a first-class feature: one public JSON-over-WebSocket protocol (uo-proto::msg) for the client, scripts and agents alike — bots are ordinary players, no privileged bot API. Rust SDK in uo-sdk; MCP server in uo-mcp for LLM agents.
  103. #2
    Language: Rust for server, client and tools.
  104. #1
    Genre/look: 2.5D isometric MMO, UO remaster. Graphics are 2D sprites, each carrying albedo + normal map + height map, so modern lights, reflections and post-processing apply per pixel.