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.
- 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.pywrites the contractassets/requests/world-props.tomland 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 intoassets/manifest.d/world_<group>.toml(briefassets/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 inuo-atlas/src/dress/(props.rsthe order as a table,scatter.rsper region,sites.rsvignettes planned once before the bake like towns, town dressing intowns.rs): the nine groups were merged (1698 sprites, 156 MB),tools/world_props_usage.pychecks 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. - 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.jsonplots). 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 (FORMAT2): records carry facing, storey, roof, exact height (TEXEL_Zsteps) and a building index into a per-chunk table of building boxes;Content::placementandground_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 roofsOverhead.world.jsonlists 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. - 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) getsdiag_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 bywalls_kitfrom each set's ownwall/postcode. 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 bybisect_planeat whole texels/levels and closed) plus kit copings (run(cap=…),coping/post_caphooks): 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 fromrun(reveal=…). The village shows every new piece in a mason's yard (the catalog test, #53). Details:docs/uo-objects.md› walls. - 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 aSpec(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/_ebacked, 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 asOverhead). New sprite names (wall_<m>_diag_a/_b[_window],_low_{x,y,corner,post},_slit_{x,y},_crenel_{x,y,corner,post}) are ordered inassets/requests/wall-pieces.mdwith their geometry contract; until baked the client draws placeholders. Walking upstairs is not possible yet (movement is 2D; taskmulti-level-movement); buildings are not in the baked world yet (towns, next stage). Details:docs/buildings.md. - 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) andatlas.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::bakedreads 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):Welcomegainsworldandworld_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 aregion_demandstore 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,yfor development. The online bot wanders only to walkable goals. Details:docs/world-atlas.md› Baked world,docs/server-architecture.md§ 6.6. - 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 storesopt_in_atandopt_in_text(the exact line).sourcecomes from?ref=/utm_source(one ref per outreach channel). After joining, two optional questions (uo_played,interests) go toPOST /api/waitlist/profilewith an HMAC token valid 1 h, then the Discord invite is shown. Honeypot + best-effort per-IP limit./privacypage. Keys only in env (KIT_API_KEY,KIT_FORM_ID,WAITLIST_SECRET), neverNEXT_PUBLIC_;npm run kit:setup -w weblists forms and creates the custom fields (--apply). Details:docs/devlog.md› Waitlist. - 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.pngmask alone leaves only a stump, because the trunk inside the crown was never baked. So trees are baked twice: manifestbare = true(uo-baker --bare,bake_sprite.py --bare) renders the same model again in the same session with everyuo_debris = "vanish"material see-through and writes a bare twin toassets/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 eachFrameRecordto its twin's (bare); a crown hides by writingbare_time/bareinto 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). Settingcrowns: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). - 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 (--sizescales 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 anindex.htmlviewer (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. - 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.rsturns mouse and keys intoIntents (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.rsoffline 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.rsin an offline battle (battle orders; a held direction orders oneMovestep whenever nothing else is ordered, as the server does),online/input.rsas server commands with prediction. Each mode fillscontrols::Scope(pickable mobiles, known spells, enabled, in a fight) before the controls run.hud.rsis the one HUD (status panel top left, vitals, spell bar, target card, log, outcome banner, overhead bars, floating text) drawn fromHudModelandGameLog, 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). - 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
Phasehas a weak cool moon (0.25, 0.4, 1 × 0.05) and a dim blue sky (20), exposure +0.5 EV;TOON_T00.003 (black only as a guard). New per-phase dim tint (Phase::dim_tint+dim_tint_amount, uploaded asShading::dim_tint, applied intoon_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 throughLook::light_color: saturationlight_saturation0.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. - 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 asStep { dir }; the server queues up toMAX_QUEUED_STEPS4, takes them in order one per step time as soon as nothing else is ordered, checks each (walkable, free) and refuses a bad one withStepRejected(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. - 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
Attrmodifiers 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 staysAP_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). - 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-fighterFighter::step_msinuo-combat(default 2 AP, so the offline demo and mobs are unchanged); the server publishes the player's current step time inEntityState::step_msand the client times its predicted steps by it; the run animation follows from ground speed (#48). - 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 untildir: null; the server steps it one tile per step time (2 AP) whenever the player has no other order (a held heading and aWalkTocome 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 runexercises it. - 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). - 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 oneMemStorewith real TCP;mem: one all-roles node), optional realuo-client --headless --bot combat-autoclients, 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.jsonwith 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 fromdeploy/docker-compose.yml(on this machinedocker pullhangs in Docker Desktop's credential helper: fetch the image withcraneand an emptyDOCKER_CONFIG, thendocker 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). - 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.rsthread: 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 (GameMapcarries 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 fromuo_world::cached_contentwithin 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 fromMovedevents, with deaths, corpses and respawns; combat events reuse the offline demo's presentation (combat/present.rshelpers shared, offline unchanged); HUD: status, vitals, spell bar (fromWelcome.spells), target card, log, overhead bars; input as in the combat demo.--bot combat-autoplays by itself (nearest bandit, known spells, heal/potion when low, next unvisited camp). Terrain fixes found on the way: materials built after startup got defaultShading(black shadows) and the terrain noise hash now wraps every 289 cells (precision at large coordinates). Code:uo-client/src/online/. - 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). Oneuo-serverbinary, roles gateway (client WebSockets, sessions, area of interest radius 18, routing; no game state) and sim (regions),--role all --store memfor 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 stableObjectKeys, the combat arena builder); each region is one open-worlduo_combat::Battle(Battle::open: no end, no escape, mobs leashed to home,remove/slot reuse,Sideu32) 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 ownershipchar_owner (node, gen)by compare-and-set, the commit point of every handoff; state writes are plain LWW writesUSING 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-sessionseq(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 aResume(token, any gateway; characters linger 30 s). Store:Storetrait,MemStore(exact CAS/LWW/TTL semantics, multi-node tests in one process) and ScyllaDB/Cassandra (scylladriver, LWT withIF expires_ms < nowso lease epochs never expire, vnode keyspace). Internal protocol: MessagePack frames over TCP, a link per peer,PeerDownon loss. Protocol v2 (PROTOCOL_VERSION2):Snapshot+ per-tickDeltaof the area of interest (JSON assembled from per-entity-version fragments),Resume, combat orders (Attack,Cast,Guard,Potion,Hold,Open,Plant) andCombatEvent(=uo_combat::Eventwith entity ids, object keys),ObjectUpdate, mobs, vitals. Players are blessed warriors / archers / mages by name hash;Gather/Craftanswer "ledger off".uo-sdk: asyncSession(login, resume/failover over a gateway list, worldView) and bot behaviours. Admin HTTP:/metrics(Prometheus text),/state,/drain,/shutdown. Details:docs/server-architecture.md. - 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 motionremoved (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. - Log walls without grey chinking (user directive: the joint between logs should be darker wood, not grey):
wallset_log.pyCHINKis 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 eightwall_log_*sprites re-baked. --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.- Life-size bear-skin rug (user directive: the bear skin should fit a natural bear against the 1 m tile):
rug_furis modelled 2.25x larger (pelt ≈ 2.2 x 2.1 m), centred between four tiles withfootprint = [2, 2]likerug_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). - A changelog entry for every change (user directive; widens #78/#81, where CI only required fragments for
crates/,assets/,chain/): every change that lands onmain, 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 typedev(label Internal, append-only inCHANGE_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 todevlog-core(CHANGE_TYPE_LABELS), shared by site and bot. - 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+). Siteweb/: Next.js 16 App Router, Tailwind v4, every page static; the landing page is a 1:1 port ofsite/(screenshot diffweb/scripts/compare-home.tsat 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) fromchanges/,blog/,docs/*.md,tasks/,roadmap.yamland 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_feluccaextension: the bot's input),/docs,/decisions,/roadmap(+/roadmap/<id>).#NNin markdown links to/decisions#dNN; links to source files become plain text (private repo). Comments: thread messages fetched from the Discord REST API withforce-cache+ tagdiscord:thread:<id>, slug→thread from Upstash/Vercel KV (JSON file fallback) taggeddiscord:slug:<slug>; own Discord-markdown parser rendering React elements only; signedPOST /api/revalidate(x-felucca-signature: t=…,v1=HMAC-SHA256("t.body"), 5 min window,discord:*tags only) callsrevalidateTag(tag, { expire: 0 }); daily Vercel cron fallback. Bot in TypeScript/discord.js (shares code with the site; Rust gains nothing for I/O glue): Verceldeployment.succeededwebhook → reads/feed.json→ plain#changesmessages, 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 inbot/deploy/. Media: originals outside git (web/.media/originals,MEDIA_ORIGINALS) plusdocs/screenshots/;sharpvariants 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.jsoncommitted; R2 upload over the S3 API, never overwriting, immutable caching;next/imagecustom loader (variants, Discord?size=, Cloudflare transforms) used through the server componentImg;in markdown. Vercel rootweb/, ignored build stepweb/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 (taskdeploy-devlog). Details:docs/devlog.md. - 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(user feedback: all walls stay visible; the cabin uses no cutaway,Cutawayhides only the east wallstatics::Cutawayremains 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. - 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. - 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 touchingcrates/,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 fromdocs/*.md, decisions to be split intodocs/decisions/, kanban in the repo, never an external tracker (the LLM reads it without connectors):roadmap.yamlholds columns and milestones, each task is its own filetasks/<id>.md(frontmatter: status, milestone, area, links; parallel sessions do not conflict), the site renders the board; RSS andfeed.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#changeschannel 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 signedPOST /api/revalidateby an always-on Gateway bot (after each deploy it posts new fragments to#changesand 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 withsharp(pixel art: whole-number downscales, nearest, WebP),media-manifest.jsonin the repo, originals outside git;next/imageuses 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. - 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. - 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 (logodocs/concepts/logo-felucca-b2.png). Built comp-first with the impeccable skill (PRODUCT.md,.impeccable/: approved compmocks/comp-a2.png, direction contract insurfaces/). 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 untilon Kit since #99); Solana barely mentioned; social/CTA links aredata-waitliston<html>names an endpoint#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 wordmarksite/assets/img/logo-type-big.png(6 px art pixel), shipped as a 1:1 master and scaled only by whole multiples withimage-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). - 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.rsturns every mobile in view (SpriteViewwithoutStatic) into a circle in render-target texels (centre at chest heightCHEST0.95, exact viaCameraState::render_origin), with its view nearness and feet height; ≤MAX_CUTOUTS32, 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 (RADIUS28 texels) that is nearer the camera than the mobile byDEPTH_MARGIN0.5 and higher than its feet byMIN_HEIGHT0.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 inreconstruct, 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 settingsee_through(on in every preset). Frame cost not measured yet (machine busy with a parallel session). Details:docs/rendering.md› See-through circles. - 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.pyconverts the used models of Stylized Nature MegaKit (Standard) and Ultimate Stylized Nature intoassets/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.pybakes 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 keepfootprint = [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.pyis 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 propertyuo_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. - 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 itsfrag_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 failsGreaterEqualand 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 writesfrag_depthin both prepass and main pass must bias the main pass towards the camera (numbers 69–72 were taken by parallel branches). - 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;DoorOpenedevent; client swaps in the_opensprite), path crossing policyCross::{Nothing, Break(ap), Doors},Arena::indoor(tiles with floors) andArchetype::Rangerfor the--combat-autohero 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. - 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-visiblePlan/Building). Rules (uo-combat): obstacles get aKind(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_HITS2 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 arekegstatics with a fuse light;statics::Cutawaywalls (the cabin's south/east) and the roof hide while the player is in the yard.--headless --screenshotcaptures the scene render without a window. Details:docs/combat.md. - 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/--enablefor runs). WASD/F camera (WASDruns since #96; arrows pan) and the time-of-day keys stay. - 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;Arenawith 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);WeaponDefgainsap(dagger 4, longsword 7, mace 8, staff/tools 9, bow 8) and the halberd (id 305, 12 AP, reach 2). Clientuo-client/src/combat/:--combat-demo/--combat-autoput a bandit camp south of the village (village moved north in this mode), the arena is built from world statics (hits fromDurability, cover from bakedheight_range, iron bars low, rope none), events driveActorsteps,anim::OneShotclips, projectiles/bolts/explosions, risingwall_fieldstonepieces,HitStaticand 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--screenshotcaptures go black if the window loses focus mid-run. Details:docs/combat.md› Demo prototype. - 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 channel2 + circleAP (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-demoin the village vs dagger rogue, halberd guard, archer, mage; rules in a pureuo-combatcrate, client inuo-client/src/combat/); real time makes ephemeral rollups a requirement for the later on-chain version. Details:docs/combat.md. - User settings (user directive: debris physics and debris as quality options; prototype of a settings system):
uo-client/src/settings.rs,UserSettingsresource saved as TOML in the platform config dir (UO_SETTINGS/--settingsoverride), every field defaulted so old files load; onegraphicssection: effect switches (feed the renderToggles;keys 1–8 stay session-only debug togglesremoved 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. - 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, 4096ImpostorFx { hit_time, damage, seed, push }in a fixed-size storage buffer written only on hits; the shader animates fromglobals.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_CELL0.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 fromheighttoheight_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 exceedMAX_CHUNK0.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 propertyuo_debris = "vanish"(object_kit.vanish: leaves, flames, embers) are baked into the optionaldebris.pngmask (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: aBlast { center, radius, damage }hits every destructible static in range (damage falling off with distance², chunks thrown from the centre); breaks go through aBreakQueueprocessed within a per-frame CPU budget, each object drawn until its debris replaces it. Debris/particle amounts follow the user settings (#67): debrisphysics/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. - 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=1freezes game time so--screenshotcaptures 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 withUO_FRAME_CAP_MSand seeing whether the frame time holds; the village holds 8.2 ms at a 7-ms pace. Numbers:docs/performance.md› Simplified shadow shells. - 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,
textureLoadonly) and described by aFrameRecord(atlas texel + layer, size, offset from the anchor,height_range,emissive_scale) in a storage buffer. All impostors use oneImpostorMaterialand one unit-quad mesh; the entity'sMeshTagselects the record, a custom vertex shader sizes the quad, and the cullingAabbis set per entity (NoAutoAabb). Lazy loading keeps working: only new rects are written (render-worldwrite_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. - 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 alonglook::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 belowTOON_T0keeps 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 viaUO_LOOK(golden.sun=…,night.sky_strength=…). - 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 intocommon.wgslat 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; cameraAcesFitted+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. - 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 isfree(). - 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_kitso every vertex sits on the lattice (X/Y whole texels, Z on levels) and bakes withresolve = dominant, now the default (ground tiles keepmean). 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 shapessnap_meshor one-texel voxels; every script ends withcheck_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.pytakesrise). 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. - 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. - 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>_Headandbare/<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:CharacterAppearancebody/legs/feet/armsNone= bare skin (no more hole/underclothes), material = base skin (MatKind::BaseSkin, Skin light/dark like the head);paperdoll.rsleaves empty regionsNone.--lineupgains two seam-check rows. Details:docs/characters.md. - Worn items drive the character look (client): a mobile with a
CharacterSheet(uo_protoCharacter) and aBodyLook(gender, skin, hair, beard) gets itsCharacterAppearancederived from its equipment (uo-client/src/paperdoll.rs), re-derived on change withset_if_neq. Appearance slots hold a per-partPart { outfit, palette, dye }(the per-characterpalettefield 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_partis 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 withNewCharacter::buildand 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. bevydefault_fontfeature added. No server code or wire commands. Details:docs/characters.md› Items → appearance. - 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. - 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 deadlinessuperseded by #68: real time on the world grid): every fight, PvE and PvP, is resolved byuo_ledger. Turn order is a public FFX-style timeline: each action costs game time (UO weapon speed, spell cast time), the lowestnext_atacts, 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 signactwith 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. Sharedno_stdresolver 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. - 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; examplestools/blender/models/pixel/(crate, bookcase; since #60crate.pyandfurniture/storage.py).bake_sprite.py --resolve dominanttakes each texel's dominant surface (medoid subsample) instead of the mean (default staysmean(dominant since #60)); every frame logs mixed texels and near-threshold edge texels.uo-bakerexposes it as--resolve mean|dominantand per asset as manifestresolve = "dominant"(overrides the flag), and prints a crispness line per asset (mean/max of both stats over its frames);bake-allends 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. - Demo village (user directive: showcase all objects, several houses with different wall sets, some more than one storey):
uo-client/src/village.rsis 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 againstassets/manifest.d(add new catalog assets to the village). Buildings are data (Building: wall set, size, storeys, roof, floors, doors, stair flights, items); storeysatz + 2.2 s(nowWALL_HEIGHT= 48 px, #60), its floor skips the stairwell of the flight below; a flight = 4step_s/step_etiles rising 0.55 (now 12 px, #60) withstairs_*_blockunderneath; windows on wall tiles at offset 2 mod 3; fences reuse the wall ring with gates. Upper storeys and roofs carryOverhead { area, level, roof };RoofView(Rcycles (Hsince #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. - 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 (catalogItemDef::mods+ instance props) and keyedEffects (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), sparseSkills,Lockup/down/locked for skills and stats.Rulesetis serializable data (caps, linear vital/carry formulas, creation limits), defaultRENAISSANCE(225/100 stats, 700.0/100.0 skills, hits = Str, 40 + 3.5·Str stones, creation 80 stat points + ≤ 3 skills summing 100.0).ItemDefis 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 inmain_handand blockoff_hand. Inventory =Possessions, a flat item treeuid -> {item, place}(place = slot or container uid), backpack = the item in thebackpackslot; server-allocatedItemUids; structural rules (slot match, two-handed, no cycles, nested container limits 125 items / 400 stones) enforced by every operation, requirements byCharacter::equipagainst 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. - Static object layer (world objects in the client):
uo_proto::objects::StaticObject { sprite, x, y, z, facing }— origin at the tile centre,zabsolute (world units), multi-tile_s/_eassets 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 sheetsDirectionorder;variants > 1→ per-tile hash. Roofs:hip_roof()picks each tile's piece from its nearest outer edges, eaves atWALL_HEIGHT2.2, +ROOF_COURSE1.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 withR/--hide-roofs, keeping their shadow shells (roof hiding superseded by #53:Overhead,--roofs). Edge blocking from names (blocked_edges). Client:Staticcomponent →SpriteView; sprites outsideSPRITE_NAMESload on first use (SpriteLoader). Lights from emissive texels: glowing texels (the ones excluded from shadow shells) give a per-frameSpriteLight(mean 3D position, colour); statics get a childPointLight(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. - 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 fromdocs/object-brief.md, developed onfeat/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), manifestparams→ envUO_PARAM_<KEY>(one script, many assets), group manifestsassets/manifest.d/*.toml(paths relative to their file). Scripts intools/blender/models/<group>/onobject_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. - 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. - 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.pyintoassets/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 sharedAnimationGraph,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 →Activityare future work; protocol unchanged. Details:docs/characters.md. Bare body parts cut the same way: #55. - Terrain shader supports edge-matched (Wang) ground variants (#46): ground layers are laid out per terrain (
TerrainInfotable in the terrain uniform: first layer, variant count, Wangper_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'stiles::pick_variantwith 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. - 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 usesvariant = combo * per_combo + hash(cell) % per_combowithcombo = n | e<<1 | s<<2 | w<<3(rule indocs/sprite-format.md; the client must pick variants this way). Edge features are built fromedge_rngin edge coordinates so both tiles of an edge build the same feature; objects straddling an edge are cut withclip_to_tile; features stayCORNERaway 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-checkcompares each edge only between variants of the same edge type. - Terrain is textured with the baked ground tiles (#42).
uo_proto::map::Terrainextended append-only to the 14 UO base types: id = discriminant = texture layer,Terrain::ALL,name()= serde name, spriteground_<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 shadertextureLoads 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. - 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-turnoptional). Models live inassets/models/, exported from the procedural Blender scripts bytools/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 forToonMeshMaterial(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 aSpriteViewfor position/facing so walking and network interpolation are shared. Skinned animation is not wired yet (no rigged model chosen). - Ground tile variants (user directive: 5–10 per tile, mixed, no visible seams): every
ground_*is baked as 8 variants in one sheet (variants = 8in the manifest; the model script runs withUO_VARIANT=0..7;sprite.jsongets"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 withedge_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-checkverifies edge agreement and renders a random-mix mosaic. The client picks a variant per cell deterministically (hash of cell coords). - 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); namedground_<type>, list and per-tile checklist indocs/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).voidhas no sprite. Ground tiles must tile seamlessly (periodic heights, edge grout split half/half). - 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.
- 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).
- Stylized pipeline implemented: outline pass in
Core3dSystems::EarlyPostProcesson the HDR frame (constants inuo-client/src/outline.rs; sprites tagged via normal-prepass alpha 1/3, terrain 1); toon constantsTOON_*inshaders/common.wgsl; WGSLPXsubstituted fromiso::PX_PER_UNITat 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. - 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. - 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-bakerpasses--tile-pxfromuo_proto::iso::TILE_W(no hardcoded tile size in Python). Modelling rule: think in texels (1 texel ≈ 0.045 units at 32 px tiles). height_backis 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.- 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. - 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. - 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). - Perf rule: never mutate a resource or asset every frame unless it actually changed (use
set_if_neq/ compare before writing) — a per-frameToggleswrite 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) withUO_FRAME_STATS=1;--perf(render GPU counters) does not work headless. - 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. - Dev profile:
debug = "line-tables-only", dependenciesopt-level = 2, debug = false(keepstarget/debugsmall; the 16 GB debug build filled the disk). - 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
--stressscene shows draw-call cost matters. - Performance methodology: measure only
--releasewith--perf --no-vsyncon a quiet machine (checkuptime; 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. Default superseded by #36.height_backis 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. Fullbake-all≈ 75 s.- 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.
- Sprite format v3 adds
height_back.png(user proposal): 16-bit, same scale asheight.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. - v2 material (metallic/roughness/specular) and emissive maps feed Bevy PBR per pixel; emission drives bloom.
- 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
RenderLayers1, which only lights see; impostor quads areNotShadowCaster. BevyContactShadows(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 inuo-client/src/lighting.rs). - 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. - 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 renderpixel_aspect_x = √2; baker self-checks the camera againstiso.rs(abort if error > 0.05 px). Assets listed inassets/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). - 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. - 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: orthographicCamera3dat 45° with a fixed non-uniform ortho area (√2 vertical stretch = oblique projection). Terrain is a real 3D mesh. - 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) andemissive.png(RGBA8 sRGB, scaled byemissive_scaleinsprite.json) so objects can be metallic/shiny and self-illuminated. Baker reads them from Principled BSDF inputs; v1 sprites load with defaults. - 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. - 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_cornerjoins both at the north-west corner,wall_postis the corner pillar; window/door/arch variants. Wall height 2.2 units, thickness ≈0.15 (now 48 px ≈ 2.12 and 2 texels, #60). - 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.
- 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.) - This file is the decision log; updating it with every new decision/directive is mandatory.
- 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). - 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 atchain/keys/(devnet/localnet only — never mainnet). 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.- v0 inventories are fungible stacks inside a per-player
CharacterPDA (["character", owner], 32 slots). Item/recipe ids are shared withuo-proto::itemsand are append-only. Migration to SPL/Token-2022 or compressed NFTs for tradable/unique items is a later decision. gatheris signed by the server's game authority key (the server attests proximity/cooldowns);craft/transfer_itemare 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.- 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. - Characters/mobiles bake 8 directions (
Direction::ALLorder, North first); static objects bake 1. Model front faces Blender −Y. 1 Blender unit = 1 tile. 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.- Asset pipeline: model in Blender via Blender MCP (from primitives / rough models) →
uo-bakerdrives 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. - 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 inuo-sdk; MCP server inuo-mcpfor LLM agents. - Language: Rust for server, client and tools.
- 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.