Character Creation

#akralon-cc{container-type:inline-size; –bg:#e8eae6; –surface:#f6f6f2; –sunk:#dcdfd9; –line:#c3c8c0; –fg:#1d232a; –muted:#5b6560; –gold:#8a5f12; –gold-soft:#e9d9b4; –verd:#2b6f61; –verd-soft:#cfe3dc; –blood:#9b2f2a; –blood-soft:#f0d6d2; –display:”Marcellus SC”,”Marcellus”,Georgia,serif; –head:”Marcellus”,Georgia,serif; –body:”Alegreya Sans”,system-ui,-apple-system,”Segoe UI”,sans-serif; –mono:”IBM Plex Mono”,ui-monospace,Menlo,Consolas,monospace; } @media (prefers-color-scheme: dark){#akralon-cc{ –bg:#13171b; –surface:#1b2026; –sunk:#0f1215; –line:#323a42; –fg:#e4e2da; –muted:#9aa39d; –gold:#e0ad4c; –gold-soft:#3a2f1b; –verd:#5cbfa8; –verd-soft:#18342e; –blood:#e2756d; –blood-soft:#3a1f1d; color-scheme:dark}} #akralon-cc *{box-sizing:border-box} #akralon-cc{background:var(–bg);color:var(–fg);font-family:var(–body);font-size:16px;line-height:1.45} #akralon-cc button,#akralon-cc input,#akralon-cc select,#akralon-cc textarea{font:inherit;color:inherit} #akralon-cc .wrap{max-width:1280px;margin:0 auto;padding-block:20px 48px;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))} #akralon-cc header.top{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap;border-bottom:1px solid var(–line);padding-bottom:12px;margin-bottom:18px} #akralon-cc .brand h1{font-family:var(–display);font-weight:400;font-size:clamp(26px,4vw,36px);letter-spacing:.04em;margin:0;line-height:1} #akralon-cc .brand p{margin:4px 0 0;color:var(–muted);font-size:14px} #akralon-cc .status{font-size:13px;color:var(–muted);display:flex;align-items:center;gap:6px} #akralon-cc .dot{width:8px;height:8px;border-radius:50%;background:var(–muted)} #akralon-cc .dot.live{background:var(–verd)} #akralon-cc .dot.warn{background:var(–blood)} #akralon-cc .layout{display:grid;grid-template-columns:290px minmax(0,1fr);gap:22px;align-items:start} @container (max-width:820px){#akralon-cc .layout{grid-template-columns:minmax(0,1fr)} #akralon-cc.has-open .roster{display:none} #akralon-cc:not(.has-open) .editor{display:none}} @container (min-width:821px){#akralon-cc .back{display:none!important}} #akralon-cc .roster{display:flex;flex-direction:column;gap:10px;position:sticky;top:calc(env(safe-area-inset-top,0px) + 12px)} #akralon-cc .roster .search{width:100%;padding:9px 11px;border:1px solid var(–line);background:var(–surface);border-radius:6px} #akralon-cc .roster-list{display:flex;flex-direction:column;gap:6px;max-height:calc(100vh – 220px);overflow:auto} #akralon-cc .rc{display:grid;grid-template-columns:44px minmax(0,1fr);gap:10px;align-items:center;padding:8px;border:1px solid transparent;border-radius:6px;background:none;text-align:left;cursor:pointer;width:100%} #akralon-cc .rc:hover{background:var(–surface)} #akralon-cc .rc.on{background:var(–surface);border-color:var(–line)} #akralon-cc .rc .pic{width:44px;height:44px;border-radius:4px;background:var(–sunk);object-fit:cover;display:grid;place-items:center;font-family:var(–display);color:var(–muted);font-size:18px;overflow:hidden} #akralon-cc .rc .nm{font-family:var(–head);font-size:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #akralon-cc .rc .sub{font-size:13px;color:var(–muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #akralon-cc .tag{display:inline-block;font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:1px 6px;border-radius:3px;background:var(–gold-soft);color:var(–gold);vertical-align:1px} #akralon-cc .tag.ok{background:var(–verd-soft);color:var(–verd)} #akralon-cc .empty{padding:18px;border:1px dashed var(–line);border-radius:6px;color:var(–muted);font-size:14px} #akralon-cc .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 14px;border-radius:6px;border:1px solid var(–line);background:var(–surface);cursor:pointer;font-weight:500;min-height:38px} #akralon-cc .btn:hover{border-color:var(–fg)} #akralon-cc .btn.pri{background:var(–fg);color:var(–bg);border-color:var(–fg)} #akralon-cc .btn.gold{background:var(–gold);color:var(–bg);border-color:var(–gold)} #akralon-cc .btn.ghost{background:none} #akralon-cc .btn.danger{color:var(–blood);border-color:var(–blood)} #akralon-cc .btn:disabled{opacity:.45;cursor:not-allowed} #akralon-cc .btn.sm{padding:4px 9px;min-height:30px;font-size:14px} #akralon-cc :focus-visible{outline:2px solid var(–gold);outline-offset:2px} #akralon-cc .editor{min-width:0} #akralon-cc .ed-head{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:10px} #akralon-cc .ed-head h2{font-family:var(–display);font-weight:400;font-size:clamp(24px,3.4vw,32px);margin:0;line-height:1.1;text-wrap:balance} #akralon-cc .ed-head .meta{color:var(–muted);font-size:14px} #akralon-cc .tabs{display:flex;gap:2px;border-bottom:1px solid var(–line);overflow-x:auto;margin-bottom:18px;scrollbar-width:thin} #akralon-cc .tab{padding:9px 12px;border:none;background:none;cursor:pointer;color:var(–muted);border-bottom:2px solid transparent;white-space:nowrap;font-size:15px;display:flex;gap:6px;align-items:baseline} #akralon-cc .tab .n{font-family:var(–mono);font-size:11px} #akralon-cc .tab.on{color:var(–fg);border-bottom-color:var(–gold)} #akralon-cc .tab.done .n{color:var(–verd)} #akralon-cc .panel{display:flex;flex-direction:column;gap:18px} #akralon-cc .card{background:var(–surface);border:1px solid var(–line);border-radius:8px;padding:16px} #akralon-cc .card h3{font-family:var(–head);font-weight:400;font-size:20px;margin:0 0 4px} #akralon-cc .lede{color:var(–muted);margin:0 0 12px;max-width:68ch;font-size:15px} #akralon-cc .rule{font-size:14px;color:var(–muted);border-left:2px solid var(–gold);padding-left:10px;margin:8px 0;max-width:72ch} #akralon-cc .flag{font-size:14px;background:var(–blood-soft);color:var(–blood);padding:8px 10px;border-radius:6px;margin:8px 0} #akralon-cc .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center} #akralon-cc .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px} #akralon-cc label.f{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(–muted);min-width:0} #akralon-cc label.f input,#akralon-cc label.f select,#akralon-cc label.f textarea,#akralon-cc .inp{padding:8px 10px;border:1px solid var(–line);border-radius:6px;background:var(–bg);color:var(–fg);font-size:15px;width:100%} #akralon-cc textarea{min-height:90px;resize:vertical} #akralon-cc .num{font-family:var(–mono);font-variant-numeric:tabular-nums} #akralon-cc .fate{display:flex;gap:8px;align-items:center} #akralon-cc .fate i{width:22px;height:22px;border-radius:50%;border:2px solid var(–gold);display:inline-block} #akralon-cc .fate i.full{background:var(–gold)} #akralon-cc .attrs{display:grid;grid-template-columns:repeat(auto-fill,minmax(118px,1fr));gap:10px} #akralon-cc .slot{border:1px solid var(–line);border-radius:8px;padding:10px;background:var(–bg);display:flex;flex-direction:column;gap:2px;text-align:left;min-height:96px} #akralon-cc button.slot{cursor:pointer} #akralon-cc button.slot.target{border-color:var(–gold);border-style:dashed;background:var(–gold-soft)} #akralon-cc .slot .k{font-size:12px;letter-spacing:.1em;color:var(–muted)} #akralon-cc .slot .v{font-family:var(–mono);font-size:28px;line-height:1.1} #akralon-cc .slot .m{font-family:var(–mono);font-size:13px;color:var(–muted)} #akralon-cc .slot .gov{font-size:12px;color:var(–muted)} #akralon-cc .dice{display:flex;gap:6px;align-items:center} #akralon-cc .die{width:38px;height:38px;border-radius:7px;border:2px solid var(–fg);display:grid;place-items:center;font-family:var(–mono);font-weight:600;font-size:18px;background:var(–surface)} #akralon-cc .die.roll{animation:akcc-tumble .45s ease-out} @keyframes akcc-tumble{0%{transform:rotate(-120deg) scale(.6);opacity:.3}100%{transform:none;opacity:1}} @media (prefers-reduced-motion:reduce){#akralon-cc .die.roll{animation:none}} #akralon-cc .pending{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px;border-radius:8px;background:var(–gold-soft)} #akralon-cc .pending .total{font-family:var(–mono);font-size:30px;color:var(–gold)} #akralon-cc .picks{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px} #akralon-cc .pick{border:1px solid var(–line);border-radius:8px;padding:10px 12px;background:var(–bg);text-align:left;cursor:pointer;display:flex;flex-direction:column;gap:4px} #akralon-cc .pick:hover{border-color:var(–fg)} #akralon-cc .pick.on{border-color:var(–gold);box-shadow:inset 0 0 0 1px var(–gold)} #akralon-cc .pick .t{font-family:var(–head);font-size:17px} #akralon-cc .pick .g{font-size:12px;color:var(–muted);text-transform:uppercase;letter-spacing:.06em} #akralon-cc .pick .d{font-size:13px;color:var(–muted)} #akralon-cc .mods{display:flex;gap:4px;flex-wrap:wrap} #akralon-cc .mod{font-family:var(–mono);font-size:12px;padding:1px 5px;border-radius:3px;background:var(–sunk)} #akralon-cc .mod.p{background:var(–verd-soft);color:var(–verd)} #akralon-cc .mod.n{background:var(–blood-soft);color:var(–blood)} #akralon-cc .tbl-wrap{overflow-x:auto} #akralon-cc table{border-collapse:collapse;width:100%;font-size:14px} #akralon-cc th,#akralon-cc td{text-align:left;padding:6px 8px;border-bottom:1px solid var(–line);vertical-align:top} #akralon-cc th{font-weight:500;color:var(–muted);font-size:12px;letter-spacing:.06em;text-transform:uppercase} #akralon-cc td.n,#akralon-cc th.n{text-align:right;font-family:var(–mono);font-variant-numeric:tabular-nums} #akralon-cc .stepper{display:inline-flex;align-items:center;gap:4px} #akralon-cc .stepper button{width:28px;height:28px;border-radius:5px;border:1px solid var(–line);background:var(–surface);cursor:pointer} #akralon-cc .stepper button:disabled{opacity:.35;cursor:not-allowed} #akralon-cc .stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px} #akralon-cc .stat{border:1px solid var(–line);border-radius:8px;padding:10px;background:var(–bg)} #akralon-cc .stat .k{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(–muted)} #akralon-cc .stat .v{font-family:var(–mono);font-size:24px} #akralon-cc .stat .x{font-size:12px;color:var(–muted)} #akralon-cc .rolls{display:flex;gap:8px;flex-wrap:wrap;align-items:center} #akralon-cc .rollbox{display:flex;flex-direction:column;align-items:center;gap:3px;font-size:12px;color:var(–muted)} #akralon-cc .rollbox input{width:58px;text-align:center;font-family:var(–mono);padding:6px;border:1px solid var(–line);border-radius:6px;background:var(–bg)} #akralon-cc .shop{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:14px} #akralon-cc .shop details{border:1px solid var(–line);border-radius:8px;background:var(–bg)} #akralon-cc .shop summary{padding:10px 12px;cursor:pointer;font-family:var(–head);font-size:17px} #akralon-cc .shop table td:last-child{width:1%} #akralon-cc .sheet{display:grid;grid-template-columns:200px minmax(0,1fr);gap:18px} @container (max-width:640px){#akralon-cc .sheet{grid-template-columns:minmax(0,1fr)}} #akralon-cc .portrait{width:100%;aspect-ratio:4/5;max-width:100%;border-radius:8px;background:var(–sunk);object-fit:cover;display:grid;place-items:center;color:var(–muted);font-family:var(–display);font-size:48px;border:1px solid var(–line);overflow:hidden} #akralon-cc .meter{display:flex;align-items:center;gap:8px} #akralon-cc .bar{flex:1;height:8px;border-radius:4px;background:var(–sunk);overflow:hidden;min-width:60px} #akralon-cc .bar span{display:block;height:100%;background:var(–verd)} #akralon-cc .bar.mag span{background:var(–gold)} #akralon-cc .bar.xp span{background:var(–fg)} #akralon-cc .toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(16px + env(safe-area-inset-bottom,0px));background:var(–fg);color:var(–bg);padding:9px 14px;border-radius:6px;font-size:14px;z-index:20;max-width:calc(100% – 32px)} #akralon-cc .confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px;border:1px solid var(–blood);border-radius:6px;background:var(–blood-soft)} #akralon-cc .list-row{display:grid;grid-template-columns:minmax(0,2fr) 90px 70px minmax(0,2fr) 34px;gap:8px;align-items:center} @container (max-width:640px){#akralon-cc .list-row{grid-template-columns:minmax(0,1fr) 80px 60px 34px} #akralon-cc .list-row .nt{display:none}} #akralon-cc .hint{font-size:13px;color:var(–muted)} #akralon-cc .welcome{display:flex;flex-direction:column;gap:14px} #akralon-cc .welcome h2{font-family:var(–display);font-weight:400;font-size:28px;margin:0} #akralon-cc .steps-ol{margin:0;padding-left:20px;color:var(–muted);max-width:60ch} #akralon-cc .steps-ol li{margin:3px 0} /* Guard against WordPress theme styles */ #akralon-cc button,#akralon-cc input,#akralon-cc select,#akralon-cc textarea{text-transform:none;letter-spacing:normal;box-shadow:none;margin:0;line-height:1.3;text-decoration:none;max-width:100%} #akralon-cc h1,#akralon-cc h2,#akralon-cc h3,#akralon-cc h4{color:var(–fg);text-transform:none;letter-spacing:normal;padding:0;border:0;background:none} #akralon-cc table{margin:0;border:0;background:none}#akralon-cc td,#akralon-cc th{border-left:0;border-right:0;border-top:0;background:none} #akralon-cc img{height:auto;box-shadow:none}#akralon-cc .rc .pic{height:44px} #akralon-cc ol{margin-bottom:0}#akralon-cc p{color:inherit} #akralon-cc{font-size:16px;line-height:1.45;color:var(–fg);border-radius:10px}

Akralon Character Ledger

Character creation under the Core Rules v1.25 · saved in your browser

Connecting…
Import character file
(function(){ “use strict”; /* ============ RULES DATA (Core Rules v1.25) ============ */ const ATTRS=[“STR”,”DEX”,”CON”,”INT”,”WIS”,”CHA”]; const ATTR_NAME={STR:”Strength”,DEX:”Dexterity”,CON:”Constitution”,INT:”Intelligence”,WIS:”Wisdom”,CHA:”Charisma”}; const ATTR_GOV={STR:”might, combat damage”,DEX:”speed, precision, AC”,CON:”toughness, Life”,INT:”skills, languages”,WIS:”magica, magic resistance”,CHA:”social, likeability”}; const D=(n,s)=>({n,s}); const RACES=[ {id:”human”,name:”Human”,group:”Human”,mods:{},traits:[“Picks 2 of 3 basic skills (realm-flavored)”],life:D(1,8),mag:D(1,6),res:{}}, {id:”plainsperson”,name:”Plainsperson”,group:”Human”,mods:{WIS:1,CHA:-1},traits:[“Horsemanship”,”Hunting & Foraging”],life:D(1,8),mag:D(1,6),res:{}}, {id:”syldaer”,name:”Syldaer”,group:”Elf · sun”,mods:{STR:-1,CON:-1,INT:1,CHA:1},traits:[“Double magica regen in direct sunlight”],life:D(1,6),mag:D(1,8),res:{}}, {id:”lunaire”,name:”Lunaire”,group:”Elf · moon”,mods:{STR:-1,CON:-1,INT:1,WIS:1},traits:[“Magica regen ×2 crescent to half moon, ×3 full moon, unaffected at new moon”],life:D(1,6),mag:D(1,8),res:{}}, {id:”wyldryn”,name:”Wyldryn”,group:”Elf · wild”,mods:{STR:-1,DEX:1,CON:-1,WIS:1},traits:[“Animalspeak”],life:D(1,6),mag:D(1,8),res:{}}, {id:”dreyth”,name:”Dreyth”,group:”Elf · shadow”,mods:{DEX:1,CON:-1,INT:1,CHA:-1},traits:[“Shadowstep: 3 spaces (~15 ft), start and end in shadow”,”Nightvision”],life:D(1,6),mag:D(1,8),res:{}}, {id:”drumdar”,name:”Drumdar”,group:”Dwarf · mountain”,mods:{STR:1,CON:1,INT:-1,WIS:-1},traits:[“Mining”,”Smelting”],life:D(1,10),mag:D(1,4),res:{Magic:2}}, {id:”uldryn”,name:”Uldryn”,group:”Dwarf · cave”,mods:{DEX:1,CON:1,WIS:-1,CHA:-2},traits:[“Mining”,”Smelting”],life:D(1,10),mag:D(1,4),res:{Magic:2}}, {id:”aradar”,name:”Aradar”,group:”Dwarf · desert”,mods:{DEX:1,CON:1,INT:-1,WIS:-1},traits:[“Mining”,”Cartography”],life:D(1,10),mag:D(1,4),res:{Magic:2}}, {id:”burwyn”,name:”Burwyn”,group:”Dwarf · hill”,mods:{CON:1,INT:-2,WIS:1},traits:[“Mining”,”Brewing”],life:D(1,10),mag:D(1,4),res:{Magic:2}}, {id:”tinker”,name:”Tinker”,group:”The Others”,mods:{STR:-2,DEX:2,CON:-2,INT:2},traits:[“Smelting”,”Smithing”,”Gemcutting”],life:D(1,6),mag:D(1,8),res:{Magic:3}}, {id:”minotaur”,name:”Minotaur”,group:”The Others”,mods:{STR:2,DEX:-2,CON:2,INT:-2},traits:[“Rage”],life:D(2,6),mag:D(1,4),res:{}}, {id:”halforc”,name:”Half-Orc”,group:”The Others”,mods:{STR:1,CON:1,INT:-1,CHA:-1},traits:[],life:D(1,10),mag:D(1,4),res:{}}, {id:”fenesian”,name:”Fenesian”,group:”The Others”,mods:{STR:-1,DEX:2,CON:-1,INT:-1,CHA:1},traits:[“Nightvision”,”Acrobatics”],life:D(1,8),mag:D(1,4),res:{}}, {id:”orthos”,name:”Orthos”,group:”The Others”,mods:{STR:-1,DEX:2,CON:1,INT:-1,CHA:-1},traits:[“Regen 1″],life:D(1,8),mag:D(1,4),res:{Disease:3,Poison:2}}, {id:”zalduran”,name:”Zalduran”,group:”The Others”,mods:{STR:-1,CON:-1,INT:2,WIS:2,CHA:-2},traits:[“Astronomy”,”Cartography”,”Ancient Lore”],life:D(1,8),mag:D(1,8),res:{}}, ]; const RACE=Object.fromEntries(RACES.map(r=>[r.id,r])); const REALMS=[“Tyrena”,”Argoth”,”Sunaria”,”Vanadryn”,”Pyrenos”,”Mordua”,”Mishura”,”Athyria”,”West Turiak”,”East Turiak”,”Kuldemork”,”Ystarya”,”Isenvegg”]; const PATHS=[ {id:”strength”,name:”Path of Strength”,role:”Fighter”,xp:”+10% combat XP”,caster:false,schools:[{name:null,prime:null,classes:[“Warrior”,”Knight”,”Barbarian”]}],blurb:”Direct, brave, seeking fame and glory where danger is thickest.”}, {id:”stealth”,name:”Path of Stealth”,role:”Thief”,xp:”+10% treasure XP”,caster:false,crit:19,schools:[{name:null,prime:null,classes:[“Rogue”,”Burglar”,”Assassin”]}],blurb:”Working in shadow and secret. Stealth classes CRIT on 19+.”}, {id:”survival”,name:”Path of Survival”,role:”Survivalist”,xp:”+10% exploration XP”,caster:false,schools:[{name:null,prime:null,classes:[“Ranger”,”Warden”,”Beastmaster”]}],blurb:”Explorers of the wild: hunting, trapping, foraging, wanderlust.”}, {id:”knowledge”,name:”Path of Knowledge”,role:”Elemental mage”,xp:”Achievement XP (new knowledge)”,caster:true,schools:[ {name:”Earthshaping (Terra Formatum)”,prime:”WIS”,classes:[“Druid”,”Abjuror”,”Summoner”],arms:”Staff or dagger. May wear leather (Leather, Furs, Splint Leather, Leather Helm).”}, {name:”Firetracing (Ignis Domito)”,prime:”INT”,classes:[“Conjuror”,”Geomancer”,”Channeler”],arms:”Staff or dagger. Cloth only.”}, {name:”Waterdancing (Aquae Chorum)”,prime:”DEX”,classes:[“Illusionist”,”Sorcerer”,”Elementalist”],arms:”Staff, dagger or rapier. Cloth only.”}, {name:”Windweaving (Ventus Vocatum)”,prime:”CHA”,classes:[“Wizard”,”Tempestarii”,”Bard”],arms:”Metal staff or dagger. Cloth only.”}],blurb:”Four elemental schools; each requires a high prime attribute.”}, {id:”faith”,name:”Path of Faith”,role:”Divine caster”,xp:”Achievement XP (faith-based acts)”,caster:true,schools:[ {name:”Lightbending (Solem Inflexio)”,prime:”WIS”,classes:[“Priest”,”Diviner”,”Enchanter”],arms:”Fluted staff or dagger. Cloth only.”}, {name:”Moonphasing (Luna Crescens)”,prime:”WIS”,classes:[“Cleric”,”Monk”,”Shaman”],arms:”Staff, dagger or any blunt weapon; may wear leather. Monk: bo staff only (nunchaku with instruction) plus unarmed.”}, {name:”Shadowbleeding (Umbra Sanguinem)”,prime:null,classes:[“Warlock”,”Witch Doctor”,”Necromancer”],arms:”Staff or dagger. Cloth only. No attribute adds spell power; the blood’s Potency does.”}],blurb:”Devotional acts unlock higher-tier spells.”}, ]; const PATH=Object.fromEntries(PATHS.map(p=>[p.id,p])); function schoolOf(pathId,cls){const p=PATH[pathId];if(!p)return null;return p.schools.find(s=>s.classes.includes(cls))||null} const XP_TABLE=[0,200,400,800,1600,3200,6400,12800,25600,51200,102400,204800,409600]; /* Gear (prices in silver; 1g = 1000s) */ const SHOPS=[ {name:”General Store”,items:[ [“Rope”,3,”Reach 3, Escape Pit”],[“1d6 Bandages”,5,”Heal 3″],[“1d6 Provisions”,5,”Heal 2. Perishable. 5s per”],[“1d6 Beer Casks”,10,”+1 STR (+2 Drumdar), −1 To Hit. 10s per”],[“1d6 Door Spikes”,5,”[2] Hold Door (20 hp avg)”],[“3d6 Rations”,3,”Full day per ration”],[“Bedroll”,2,”Full night’s rest outdoors in fair weather”],[“Tent (sleeps 2)”,10,”With bedrolls, a full night’s rest in any weather”]]}, {name:”Weaponsmith · Blunt”,cat:”weapon”,items:[[“Quarterstaff”,5,”1d4+3, Diag”],[“Knuckleduster”,10,”1d6+2″],[“Mace”,25,”1d6+3″],[“Warhammer”,40,”1d8+3, Crit 18″],[“Flail”,60,”1d8+3, Crit 18, −2 To Hit, Diag”],[“Maul”,60,”1d12+3, 2H, Crit 18″]]}, {name:”Weaponsmith · Slashing”,cat:”weapon”,items:[[“Dagger”,7,”1d4+3, Range 4″],[“Shortsword”,20,”1d6+3″],[“Sword”,40,”1d8+3″],[“Longsword”,40,”1d8+3, 2H, Diag”],[“Broadsword”,50,”1d10+3, 2H”],[“Greatsword”,70,”1d12+3, 2H, −2 To Hit”],[“War Axe”,15,”1d8+3″],[“Battleaxe”,50,”1d10+3, 2H”],[“Throwing Axe”,30,”1d6+3, Range 3″],[“Whip”,35,”1d8+2, Range 3, −2 To Hit, [1] Trip 9, [4] Coil 8″]]}, {name:”Weaponsmith · Piercing”,cat:”weapon”,items:[[“2d6 Kunai”,4,”1d4+2, Range 4, Concealable”],[“2d6 Shuriken”,10,”1d4+2, Range 3, Ignore 1 Armor”],[“Dirk”,10,”1d4+2, Ignore 1 Armor, Concealable”],[“1d6 Javelin”,15,”1d6+2, Range 5″],[“Spear”,20,”1d8+3, Diag, Range 4″]]}, {name:”Fletcher”,cat:”weapon”,ranged:true,items:[[“Short Bow”,15,”1d6+3, Range 6, ignores 1 Armor”],[“Hunting Bow”,30,”1d8+3, Range 8, ignores 1 Armor”],[“Longbow”,45,”1d10+3, Range 10, ignores 1 Armor”],[“Crossbow”,100,”1d10+3, Range 8, ignores 2 Armor”],[“Arrows”,2,”For bows · 1 Adventure worth”],[“Quarrels”,3,”For crossbows · 1 Adventure worth”]]}, {name:”Armourer · Armor”,cat:”armor”,items:[[“Leather”,4,”Armor 1, Breaks on Crit”,{ac:1,armor:1}],[“Furs”,30,”Armor 1 (Armor 2 vs Range)”,{ac:1,armor:1}],[“Splint Leather”,50,”Armor 2″,{ac:1,armor:2}],[“Ringmail”,100,”Armor 3 (Armor 2 vs Range)”,{ac:2,armor:3,metal:1}],[“Scale Armor”,400,”Armor 3″,{ac:2,armor:3,metal:1}],[“Chainmail”,600,”Armor 4 (5 vs Range), −1 DEX. Heavy”,{ac:2,armor:4,pen:1,heavy:1,metal:1}],[“Banded Mail”,1000,”Armor 5, −2 DEX. Heavy”,{ac:3,armor:5,pen:2,heavy:1,metal:1}],[“Plate Mail”,2000,”Armor 6, −3 DEX. Heavy”,{ac:3,armor:6,pen:3,heavy:1,metal:1}]]}, {name:”Armourer · Shields & Helms”,cat:”shield”,items:[[“Buckler”,20,”Armor 1, Block 10″,{ac:0,armor:1,slot:”shield”}],[“Shield”,60,”Armor 2, Block 9″,{ac:1,armor:2,slot:”shield”}],[“Kite Shield”,90,”Armor 3, Block 8, −1 DEX. Heavy”,{ac:2,armor:3,pen:1,heavy:1,slot:”shield”,metal:1}],[“Leather Helm”,10,”Armor 1, Breaks on Crit”,{ac:0,armor:1,slot:”helm”}],[“Open Helm”,50,”Armor 2, Breaks on Crit”,{ac:0,armor:2,slot:”helm”,metal:1}],[“Warhelm”,100,”Armor 2″,{ac:0,armor:2,slot:”helm”,metal:1}]]}, ]; const ITEM={};SHOPS.forEach(s=>s.items.forEach(([n,c,r,x])=>{ITEM[n]={name:n,cost:c,rules:r,shop:s.name,cat:s.cat||”gear”,ranged:!!s.ranged,…(x||{}),slot:(x&&x.slot)||(s.cat===”armor”?”body”:undefined)}})); /* ============ HELPERS ============ */ const ROOT=document.getElementById(“akralon-cc”);if(!ROOT)return;const $=s=>ROOT.querySelector(s); const esc=s=>String(s??””).replace(/[&”‘]/g,c=>({“&”:”&”,””:”>”,’”‘:”"”,”‘”:”'”}[c])); const rnd=s=>{const a=new Uint32Array(1);crypto.getRandomValues(a);return 1+(a[0]%s)}; const rollDice=(n,s)=>Array.from({length:n},()=>rnd(s)); const sum=a=>a.reduce((x,y)=>x+y,0); const sg=n=>n>0?”+”+n:nn>0?”+”+n:n<0?"−"+Math.abs(n):"—"; const ATTR_MIN=2, ATTR_MAX=19; function modOf(v){if(v==null||isNaN(v))return 0;if(v<=3)return -3;if(v<=5)return -2;if(v<=8)return -1;if(v<=12)return 0;if(v<=15)return 1;if(vJSON.parse(JSON.stringify(o)); const TAB=Math.random().toString(36).slice(2,10); let toastT;function toast(m){const t=$(“#toast”);t.textContent=m;t.hidden=false;clearTimeout(toastT);toastT=setTimeout(()=>t.hidden=true,2600)} /* ============ STATE ============ */ const S={chars:new Map(),cur:null,tab:null,db:null,user:null,uid:null,live:false,readonly:false,q:””,confirmDel:false,levelUp:null,dirtyRemote:false}; function blankChar(){ return {name:””,mode:”advanced”,level:0,xp:0,status:”draft”,createdBy:S.uid||null,createdAt:Date.now(), portrait:””,appearance:””,backstory:””,notes:””,realm:””,humanSkills:””, self:{stage:”unborn”,method:null,fate:3,attempt:0,pending:null,attempts:[]}, rolled:{},luck:null,fateLeft:null,race:””,luckMoves:{},growth:[], path:””,cls:””,lifeRolls:[],magRolls:[],usesMagica:null,lifeNow:null,magNow:null,fateSpent:0, skills:[],silver:null,silverRolls:null,gear:[],log:[]}; } /* ============ DERIVED STATS ============ */ function derive(c){ const r=RACE[c.race]; const fin={},base={},rm={},raw={}; const moved=sum(Object.values(c.luckMoves||{})); for(const a of ATTRS){ const b=c.rolled?.[a]; base[a]=b??null; rm[a]=r?.mods[a]||0; const g=(c.growth||[]).filter(x=>x.attr===a).length; fin[a]=b==null?null:Math.min(ATTR_MAX,Math.max(ATTR_MIN,b+rm[a]+(c.luckMoves?.[a]||0)+g)); raw[a]=b==null?null:b+rm[a]+(c.luckMoves?.[a]||0)+g; } const luck=c.luck==null?null:c.luck-moved; const M=Object.fromEntries(ATTRS.map(a=>[a,modOf(fin[a])])); const luckMod=modOf(luck); const path=PATH[c.path]; const school=schoolOf(c.path,c.cls); const caster=c.usesMagica??(path?path.caster:false); const lifeDie=r?.life||D(1,8), magDie=r?.mag||D(1,6); const need=(c.level||0)+1; const lifeMax=(c.lifeRolls||[]).filter(x=>x!=null&&x!==””).reduce((t,x)=>t+Math.max(1,Number(x)+M.CON),0); const magMax=caster?(c.magRolls||[]).filter(x=>x!=null&&x!==””).reduce((t,x)=>t+Number(x)+M.WIS,0):0; const toHit=M.STR+M.DEX; const dex=fin.DEX; let baseAC=null; if(dex!=null) baseAC=(dex>=9&&dex12?12+M.DEX:9+M.DEX); const eq=(c.gear||[]).filter(g=>g.equipped&&ITEM[g.name]); let acAdd=0,armor=0,pen=0,heavy=false,metal=false; eq.forEach(g=>{const it=ITEM[g.name];acAdd+=it.ac||0;armor+=it.armor||0;pen+=it.pen||0;if(it.heavy)heavy=true;if(it.metal)metal=true}); const effPen=Math.max(0,pen-Math.max(0,M.STR)); const ac=baseAC==null?null:baseAC+acAdd-effPen; const spPer=Math.max(1,5+M.INT); const spStart=Math.max(1,5+M.INT)+(c.fateLeft||0); const spTotal=spStart+(c.level||0)*spPer; const spSpent=sum((c.skills||[]).map(s=>Number(s.cost)||0)); const fateEarned=(c.fateLeft||0)+(c.level||0)*(1+luckMod); const res={…(r?.res||{})}; return {r,base,rm,raw,fin,M,luck,luckMod,moved,path,school,caster,lifeDie,magDie,need,lifeMax,magMax,toHit,baseAC,ac,armor,pen,effPen,heavy,metal, move:dex==null?null:Math.ceil(dex/2),spPer,spStart,spTotal,spSpent,fateEarned,fateNow:fateEarned-(c.fateSpent||0),crit:path?.crit||20,res}; } function selfDone(c){return c.self?.stage===”done”} function stepDone(c,k){ switch(k){case”begin”:return!!c.name;case”self”:return selfDone(c);case”race”:return!!c.race;case”path”:return!!c.cls; case”stats”:{const d=derive(c);return (c.lifeRolls||[]).filter(x=>x!==””&&x!=null).length>=d.need&&(!d.caster||(c.magRolls||[]).filter(x=>x!==””&&x!=null).length>=d.need)} case”skills”:return(c.skills||[]).length>0;case”gear”:return c.silver!=null;default:return false} } /* ============ PERSISTENCE ============ */ const timers={},chains={}; function saveSoon(id){flush(id)} addEventListener(“pagehide”,()=>{for(const id of Object.keys(timers))flush(id)}); document.addEventListener(“visibilitychange”,()=>{if(document.visibilityState===”hidden”)for(const id of Object.keys(timers))flush(id)}); const KEY=”akralon-cc-characters-v1″; function persist(){try{localStorage.setItem(KEY,JSON.stringify(Object.fromEntries(S.chars)))}catch(e){toast(“This browser couldn’t save. Download your character file to keep it, or remove a large portrait.”)}} function flush(id){ clearTimeout(timers[id]);delete timers[id];persist();return; if(!S.live||S.readonly)return; const c=S.chars.get(id);if(!c)return; const body={…clone(c),_tab:TAB,updatedAt:Date.now()}; chains[id]=(chains[id]||Promise.resolve()).then(()=>S.db.collection(“characters”).doc(id).set(body)).catch(e=>{ if(e&&e.code===”invalid_argument”&&!e.message?.includes(“256”)){S.readonly=true;setStatus();toast(“You can view this roster but not change it.”)} else if(e&&e.code===”quota_exceeded”)toast(“Storage is full. Delete a character or a large portrait.”); else toast(“Couldn’t save just now. Your change is kept on this page and will retry with the next edit.”); }); } function mutate(fn,{render=true,ms}={}){const c=S.chars.get(S.cur);if(!c||S.readonly)return;fn(c);saveSoon(S.cur,ms);if(render)renderAll()} /* ============ RENDER: ROSTER ============ */ let profiles={}; async function refreshProfiles(){ if(!S.user)return;const ids=[…new Set([…S.chars.values()].map(c=>c.createdBy).filter(Boolean))]; if(!ids.length)return;try{profiles=await S.user.profiles(ids);renderRoster();if(S.cur)renderHeadMeta()}catch{} } function subline(c){const r=RACE[c.race];const lvl=c.level||0; const cls=c.cls?(lvl===0?”aspiring “+c.cls:c.cls):”no path yet”;return `${r?r.name:”Unshaped”} · ${cls} · L${lvl}`} function initials(n){return esc((n||”?”).trim().split(/\s+/).map(w=>w[0]).slice(0,2).join(“”).toUpperCase()||”?”)} function renderRoster(){ const el=$(“#roster”);const q=S.q.toLowerCase(); const list=[…S.chars.entries()].filter(([,c])=>!q||[c.name,RACE[c.race]?.name,c.cls,c.realm].join(” “).toLowerCase().includes(q)) .sort((a,b)=>(a[1].name||”~”).localeCompare(b[1].name||”~”)); if(!S.chars.size){el.innerHTML=`
${S.ready?”No characters yet. Press New character to spend your first Fate point. Characters are kept in this browser; download a character file to back one up or send it to your GM.”:”Loading the roster…”}
`;return} if(!list.length){el.innerHTML=`
No characters match “${esc(S.q)}”.
`;return} el.innerHTML=list.map(([id,c])=>``).join(“”); } /* ============ RENDER: EDITOR ============ */ const TABS=[[“begin”,”Begin”],[“self”,”1 · Self”],[“race”,”2 · Race”],[“path”,”3 · Path”],[“stats”,”4 · Stats”],[“skills”,”5 · Points”],[“gear”,”6 · Gear”],[“sheet”,”Sheet”]]; function renderAll(){renderRoster();renderEditor()} function renderHeadMeta(){const c=S.chars.get(S.cur);const m=$(“#headMeta”);if(!c||!m)return; const p=profiles[c.createdBy];m.textContent=`${subline(c)}${p&&p.name?” · created by “+p.name:””}`} function renderEditor(){ const ed=$(“#editor”);ROOT.classList.toggle(“has-open”,!!S.cur); const c=S.chars.get(S.cur); if(!c){ed.innerHTML=welcome();return} if(!S.tab)S.tab=c.status===”complete”?”sheet”:(TABS.find(([k])=>k!==”sheet”&&!stepDone(c,k))||[“sheet”])[0]; const body={begin:vBegin,self:vSelf,race:vRace,path:vPath,stats:vStats,skills:vSkills,gear:vGear,sheet:vSheet}[S.tab](c); ed.innerHTML=`

${esc(c.name||”Unnamed wanderer”)}

${TABS.map(([k,l])=>``).join(“”)} ${S.readonly?`
You can view this roster but your access doesn’t allow changes.
`:””}
${body}
`; renderHeadMeta(); } function welcome(){return `

Enter the world of Akralon

You are but three Fate points, an immaterial existence outside of Akralon. Create a hero by the six steps of the Core Rules. Your characters stay in this browser on this device. Use Download character file on the sheet to keep a backup or hand your hero to your GM.

  1. Generate Self: 3d6 per attribute, assigned as rolled, then Luck
  2. Choose a Race and drain Luck if you dare
  3. Choose your Path and Class
  4. Derive Life, Magica, To Hit and AC
  5. Spend your skill points
  6. Record your gear and coin
`} function nextBtn(k){const i=TABS.findIndex(t=>t[0]===k);const n=TABS[i+1];return n?`
`:””} /* — Begin — */ function vBegin(c){ return `

Who are you?

Name your hero and choose how your table plays.

Character name Play mode Advanced RPG · start at Level 0 Tabletop · start at Level 1 Tabletop · start at Level 2

Advanced: players start as level 0 nobodies and become their Class at Level 1 with 200 XP. Tabletop heroes can start at level 1 or 2 (normal or easy mode).

Portrait

Upload your MidJourney or Nano Banana render. It’s shrunk to a small JPEG so it saves with the character.

${c.portrait?`Portrait`:`
${initials(c.name)}
`}
Choose image ${c.portrait?``:””}
Appearance${esc(c.appearance)} Backstory${esc(c.backstory)}
${nextBtn(“begin”)}`; } /* — Self — */ function fateTokens(n){return `${[0,1,2].map(i=>`<i class="${i`).join(“”)}${n} Fate`} function vSelf(c){ const s=c.self; if(s.stage===”unborn”)return `

Generate Self

You begin holding 3 Fate points, all you own in the world. Spending one makes you a living being: you roll 3d6 six times and assign each total to an attribute as you roll it.

${fateTokens(3)}

Dislike the result? Spend another Fate to reroll the whole character (never a single attribute), at most twice. Each unspent Fate becomes +1 starting skill point. Then roll Luck, once, no reroll.

`; if(s.stage===”manual”)return manualSelf(c); const filled=ATTRS.filter(a=>c.rolled[a]!=null).length; const prev=s.attempts?.length?`
Discarded attempts (${s.attempts.length})
${ATTRS.map(a=>``).join(“”)}${s.attempts.map(t=>`${ATTRS.map(a=>``).join(“”)}`).join(“”)}
${a}
${t[a]??”–”}
`:””; if(s.stage===”rolling”){ const p=s.pending; return `

Attempt ${s.attempt} of 3

${fateTokens(s.fate)}

${filled<6?"Roll 3d6, then tap the attribute that receives it. Choose carefully: once placed, a value stays.":"All six are set. Keep this body, or spend Fate to start over."}

${filled<6?(p?`
${p.dice.map(d=>`${d}`).join(“”)}
${p.total}
Tap an empty attribute below to place it.
` :`
`):””}
${ATTRS.map(a=>{const v=c.rolled[a];const tgt=p&&v==null; return `${a}${v??”·”}${v!=null?sgz(modOf(v)):””}${ATTR_GOV[a]}`}).join(“”)}
${filled===6?`
<button class="btn" data-act="rerollAll" ${s.fate=3?”disabled”:””}>Reroll everything (−1 Fate)
${s.fate===1&&s.attempt<3?`

This is your last Fate. Spend it and you must keep whatever comes, walking in with 0 Fate.

`:””}`:””} ${prev}
`; } if(s.stage===”luck”)return `

Luck, the seventh attribute

You’ve accepted your body. Now roll 3d6 one more time. No rerolls for this one.

${ATTRS.map(a=>`
${a}${c.rolled[a]}${sgz(modOf(c.rolled[a]))}
`).join(“”)}
${fateTokens(s.fate)}
`; // done return `

You are made living flesh

${fateTokens(c.fateLeft)}

${s.method===”manual”?”Entered from physical dice.”:”Rolled here on attempt “+s.attempt+”.”} Racial modifiers and any Luck drain are applied on the Race step.

${ATTRS.map(a=>`
${a}${c.rolled[a]}${sgz(modOf(c.rolled[a]))}${ATTR_GOV[a]}
`).join(“”)}
LUCK${c.luck}${sgz(modOf(c.luck))}Fate per level
${prev}
${nextBtn(“self”)}`; } function manualSelf(c){ return `

Enter your dice

Roll 3d6 at the table for each attribute in the order you assign them, then Luck last. Enter the raw totals before racial modifiers.

${ATTRS.map(a=>`${a}`).join(“”)} LUCK
Fate left after creation ${[[2,”2 · kept the first roll”],[1,”1 · rerolled once”],[0,”0 · rerolled twice”]].map(([v,l])=>`${l}`).join(“”)}
${c.self.method?””:``}
`; } /* — Race — */ function modChips(m){const e=ATTRS.filter(a=>m[a]);return e.length?e.map(a=>`0?”p”:”n”}”>${a} ${sg(m[a])}`).join(“”):`no modifiers`} function vRace(c){ const d=derive(c); const picks=RACES.map(r=>``).join(“”); const r=d.r; let drain=””; if(r&&selfDone(c)){ drain=`

Drain Luck

After racial modifiers, you may move up to 3 points out of Luck into other attributes. Luck can’t go below 3. Higher Luck means more Fate each level.

${ATTRS.map(a=>``).join(“”)}
AttributeRolledRaceFrom LuckFinalMod
${ATTR_NAME[a]}${d.base[a]}${sgz(d.rm[a])} ${c.luckMoves[a]||0} ${d.fin[a]}${sgz(d.M[a])}
Luck${c.luck}—−${d.moved}${d.luck}${sgz(d.luckMod)}

Attributes are capped between ${ATTR_MIN} and ${ATTR_MAX}.

${ATTRS.some(a=>d.raw[a]!==d.fin[a])?`

${ATTRS.filter(a=>d.raw[a]!==d.fin[a]).map(a=>`${a} would be ${d.raw[a]}`).join(“, “)}; capped to ${ATTR_MIN}–${ATTR_MAX}.

`:””}
`; } else if(r&&!selfDone(c)) drain=`
Finish Generate Self to apply these modifiers and drain Luck.
`; return `

Choose a Race

Each race has its own history, strengths and weaknesses. Racial modifiers apply after your attributes are rolled.

${picks}
${c.race&&!RACE[c.race]?`

This character’s race (${esc(c.race)}) is no longer playable. Choose another.

`:””}
Homeland realmChoose a realm${REALMS.map(x=>`${x}`).join(“”)} ${c.race===”human”?`Human basic skills (pick 2 of 3, realm-flavored)`:””}
${drain}${nextBtn(“race”)}`; } /* — Path — */ function vPath(c){ const d=derive(c);const lvl0=(c.level||0)===0; const paths=PATHS.map(p=>``).join(“”); let classes=””; if(d.path){ classes=`

${lvl0?”Your aspiration”:”Choose a Class”}

${lvl0?”At Level 0 you have no class yet. Pick the one you aspire to; you become it at Level 1 with 200 XP.”:”Your Class sets the tools of your narrative. Full skill and spell trees live in the Handbook.”}

${d.path.schools.map(s=>`${s.name?`

${esc(s.name)} ${s.prime?`prime ${s.prime} ${d.fin[s.prime]!=null?”· yours “+d.fin[s.prime]:””}`:””}

`:””}
${s.classes.map(k=>``).join(“”)}
`).join(“”)} ${d.school?.prime?`

${esc(d.school.name.split(” (“)[0])} requires a high ${ATTR_NAME[d.school.prime]}. Your ${d.school.prime} ${sgz(d.M[d.school.prime])} is added to spell damage and healing, and to the target’s save. The exact threshold for each Tier is in the Handbook.

`:””} ${d.school?.arms?`

Arms and armor: ${esc(d.school.arms)}

`:(d.path&&!d.path.caster?`

Non-casting classes are proficient with daggers and shortswords; class-specific weapons are in the Handbook. Non-proficient weapons take −4 To Hit.

`:””)}
`; } return `

Choose your Path

Will you be a famous warrior, a notorious burglar, a beast-befriending ranger, a windweaving wizard or a lightbending priest?

${paths}
${classes}${nextBtn(“path”)}`; } /* — Stats — */ function rollRow(kind,c,d){ const arr=kind===”life”?c.lifeRolls:c.magRolls;const die=kind===”life”?d.lifeDie:d.magDie;const mod=kind===”life”?d.M.CON:d.M.WIS; let h=””;for(let i=0;i<Math.max(d.need,arr.length);i++){const v=arr[i]; h+=`
${i===0?”Creation”:”Level “+i} ${v==null||v===””?``:`${kind===”life”?Math.max(1,Number(v)+mod):sg(Number(v)+mod)}`}
`} return h; } function vStats(c){ const d=derive(c); if(!selfDone(c))return `
Generate Self first; everything here comes from your attributes.
`; return `

Combat

To Hit
${sg(d.toHit)}
STR ${sgz(d.M.STR)} + DEX ${sgz(d.M.DEX)}
Armor Class
${d.ac}
base ${d.baseAC}${d.ac!==d.baseAC?` · gear ${sg(d.ac-d.baseAC)}`:””}
Armor
${d.armor}
damage absorbed per hit
Movement
${d.move}
half DEX, rounded up
Crit on
${d.crit}${d.crit<20?"+":""}
natural 20 always crits

AC: DEX 9–12 uses DEX as base. Outside that range, 12 + mod (high) or 9 + mod (low).${d.pen?` Heavy gear’s −${d.pen} DEX penalty is offset by your STR bonus, leaving −${d.effPen} on AC.`:””}

Life

${d.lifeDie.n}d${d.lifeDie.s} + CON ${sgz(d.M.CON)} for each roll, never less than 1 per roll. At 0 Life you fall unconscious; below −3 you die.

${rollRow(“life”,c,d)}

Max Life ${d.lifeMax}

The app counts one roll at creation plus one per level gained, so a Level ${c.level} hero has ${d.need}.

Magica

Uses magica ${d.path?`(default for ${esc(d.path.name)}: ${d.path.caster?”yes”:”no”})`:””} ${d.caster?`

${d.magDie.n}d${d.magDie.s} + WIS ${sgz(d.M.WIS)} for each roll. Replenishes after a full rest.

${rollRow(“mag”,c,d)}

Max Magica ${d.magMax}

`:””}

Resistances

${Object.keys(d.res).length?Object.entries(d.res).map(([k,v])=>`${k} Res +${v} `).join(“”):`None from race.`}

A Res bonus raises your target number on the save (CON for physical types, WIS for holy, necrotic and magical).

${nextBtn(“stats”)}`; } /* — Skills — */ function vSkills(c){ const d=derive(c);const left=d.spTotal-d.spSpent; return `

Spend your points

Earned
${d.spTotal}
${d.spStart} at start${c.level?` + ${c.level}×${d.spPer}`:””}
Spent
${d.spSpent}
<div class="stat" style="${left
Left
<div class="v" style="${left${left}

Start: 5 + INT mod (min 1), +1 for each Fate still held after creation (${c.fateLeft??0}). Each level: 5 + INT mod (${d.spPer}). Costs come from the class tables in the Handbook; crafts and secondary skills cost 2. Every new skill or spell still needs a trainer.

Skills & spells

NameTypeCostNotes
${(c.skills||[]).map((s,i)=>`
${[“Skill”,”Spell”,”Craft”,”Secondary”].map(k=>`${k}`).join(“”)}
`).join(“”)||`
No skills yet.
`}
${nextBtn(“skills”)}`; } /* — Gear — */ function weaponDmg(it,d){const m=it.rules.match(/(\d+d\d+)\+(\d+)/);if(!m)return””;const mod=it.ranged?d.M.DEX:d.M.STR;const t=Number(m[2])+mod;return `${m[1]}${t>=0?”+”+t:”−”+Math.abs(t)}`} function vGear(c){ const d=derive(c); const warn=[]; if(d.caster&&d.metal)warn.push(“Casters can’t wear metal armor.”); if(d.caster&&d.school&&/Cloth only/.test(d.school.arms||””)&&(c.gear||[]).some(g=>g.equipped&&ITEM[g.name]?.slot===”body”))warn.push(`${d.school.name.split(” (“)[0]} is cloth only.`); if(d.heavy)warn.push(“Heavy armor requires the Heavy Armor skill (Knight tree; Warriors may cross-buy it).”); return `

Purse

A hero starts with 3d6 × 10 silver. The GM may adjust this to your backstory. 1 gold = 1000 silver.

${c.silverRolls?`
${c.silverRolls.map(x=>`${x}`).join(“”)}
`:””} Silver ${c.silver==null?``:””}

Carried

${warn.map(w=>`

${esc(w)}

`).join(“”)} ${(c.gear||[]).length?`
${c.gear.map((g,i)=>{const it=ITEM[g.name];const eq=it&&(it.slot);return ``}).join(“”)}
ItemRulesQtyEquip
${esc(g.name)}${esc(it?it.rules:g.note||””)}${it&&it.cat===”weapon”&&/\d+d\d+\+/.test(it.rules)?` · ${weaponDmg(it,d)}`:””} ${g.qty} ${eq?“:””}
`:`
Nothing carried yet. Add from the shops below or write in a custom item.
`}

Shops

From the Core Rules’ basic stores. Region Guides carry the rest.

Pay from purse when adding
${SHOPS.map(s=>`
${esc(s.name)}
${s.items.map(([n,cost,r])=>``).join(“”)}
${esc(n)}
${esc(r)}
${cost>=1000?cost/1000+”g”:cost+”s”}
`).join(“”)}
${nextBtn(“gear”)}`; } /* — Sheet — */ function vSheet(c){ const d=derive(c);const lvl=c.level||0;const next=XP_TABLE[lvl+1]; if(!selfDone(c))return `
The sheet fills in once you’ve generated Self.
`; const lifeNow=c.lifeNow??d.lifeMax, magNow=c.magNow??d.magMax; const pct=(a,b)=>b>0?Math.max(0,Math.min(100,a/b*100)):0; const prevXp=XP_TABLE[lvl]||0; const canLevel=next!=null&&(c.xp||0)>=next; const weapons=(c.gear||[]).filter(g=>ITEM[g.name]?.cat===”weapon”&&/\d+d\d+\+/.test(ITEM[g.name].rules)); const lu=S.levelUp; return `
${c.portrait?`Portrait of ${esc(c.name)}`:`
${initials(c.name)}
`}
Race ${esc(d.r?.name||”—”)}
Realm ${esc(c.realm||”—”)}
${lvl===0?”Aspires to”:”Class”} ${esc(c.cls||”—”)}
Path ${esc(d.path?.name.replace(“Path of “,””)||”—”)}
Mode ${c.mode===”advanced”?”Advanced RPG”:”Tabletop”}
${ATTRS.map(a=>`
${a}${d.fin[a]}${sgz(d.M[a])}
`).join(“”)}
LUCK${d.luck}${sgz(d.luckMod)}
To Hit
${sg(d.toHit)}
AC
${d.ac}
Armor
${d.armor}
Move
${d.move}
Crit
${d.crit}${d.crit<20?"+":""}
Fate
${d.fateNow}
Life
${lifeNow} / ${d.lifeMax}
${d.caster?`
Magica
${magNow} / ${d.magMax}
`:””}
Level ${lvl}
/ ${next?next.toLocaleString():”max”} XP
${lu?levelUpPanel(c,d,lu):””}

Weapons

${weapons.length?`${weapons.map(g=>{const it=ITEM[g.name];return ``}).join(“”)}
${esc(g.name)}${weaponDmg(it,d)}${esc(it.rules.replace(/^\d+d\d+\+\d+,?\s*/,””))}
`:`None carried.`}

Damage includes STR ${sg(d.M.STR)} for melee and DEX ${sg(d.M.DEX)} for bows.

Traits & resistances

${(d.r?.traits||[]).map(t=>`${esc(t)}`).join(“”)}${Object.entries(d.res).map(([k,v])=>`${k} Res +${v}`).join(“”)}${c.humanSkills?`${esc(c.humanSkills)}`:””}
${d.path?`

${esc(d.path.xp)}

`:””}

Skills & spells

${(c.skills||[]).length?`${c.skills.map(s=>``).join(“”)}
${esc(s.name||”—”)}${esc(s.kind)}${esc(s.note)}
`:`None yet.`}

${d.spTotal-d.spSpent} skill points unspent.

Pack & purse

${c.silver??0} silver

${(c.gear||[]).map(g=>`${esc(g.name)}${g.qty>1?” ×”+g.qty:””}${g.equipped?” (worn)”:””}`).join(” “)||`Empty.`}
Session notes${esc(c.notes)}
${(c.log||[]).length?`

Chronicle

${c.log.slice().reverse().map(l=>``).join(“”)}
${new Date(l.t).toLocaleDateString()}${esc(l.m)}
`:””}
${c.status===”complete”?``:``} ${S.confirmDel?`Delete ${esc(c.name||”this character”)} from this browser? `:``}
`; } function levelUpPanel(c,d,lu){ const to=(c.level||0)+1;const grow=to===4||to===8; return `

Rise to Level ${to}

You gain ${d.spPer} skill points and ${1+d.luckMod} Fate (1 ${sgz(d.luckMod)} Luck). Roll Life${d.caster?” and Magica”:””}.

Life ${d.lifeDie.n}d${d.lifeDie.s}
${d.caster?`
Magica ${d.magDie.n}d${d.magDie.s}
`:””} ${grow?`Attribute growth (+1, max ${ATTR_MAX})Choose${ATTRS.map(a=>`=ATTR_MAX?”disabled”:””}>${ATTR_NAME[a]} (${d.fin[a]})`).join(“”)}`:””}
${to===1&&c.mode===”advanced”?`

At Level 1 you become your Class: ${esc(c.cls||”choose one on the Path step”)}.

`:””} ${to===4||to===7?`

Level ${to} opens Tier ${to===4?2:3} skills and spells.

`:””}
`; } function sheetText(c){ const d=derive(c);const L=[]; L.push(`${c.name||”Unnamed”} — ${d.r?.name||”?”} ${c.cls||””} (Level ${c.level||0}${(c.level||0)===0?”, aspiring”:””})`); if(c.realm)L.push(`Realm: ${c.realm}`); L.push(ATTRS.map(a=>`${a} ${d.fin[a]} (${sgz(d.M[a])})`).join(” · “)+` · LUCK ${d.luck} (${sgz(d.luckMod)})`); L.push(`Life ${c.lifeNow??d.lifeMax}/${d.lifeMax}${d.caster?` · Magica ${c.magNow??d.magMax}/${d.magMax}`:””} · To Hit ${sg(d.toHit)} · AC ${d.ac} · Armor ${d.armor} · Move ${d.move} · Crit ${d.crit} · Fate ${d.fateNow}`); L.push(`XP ${c.xp||0} · Skill points ${d.spTotal-d.spSpent} unspent · ${c.silver??0}s`); const tr=[…(d.r?.traits||[]),…Object.entries(d.res).map(([k,v])=>`${k} Res +${v}`)];if(tr.length)L.push(“Traits: “+tr.join(“, “)); if((c.skills||[]).length)L.push(“Skills: “+c.skills.map(s=>s.name).filter(Boolean).join(“, “)); if((c.gear||[]).length)L.push(“Gear: “+c.gear.map(g=>g.name+(g.qty>1?” ×”+g.qty:””)+(g.equipped?” (worn)”:””)).join(“, “)); return L.join(“\n”); } /* ============ ACTIONS ============ */ async function newChar(){ if(S.readonly){toast(“Your access doesn’t allow new characters.”);return} const id=”c”+Date.now().toString(36)+Math.random().toString(36).slice(2,6); const c=blankChar();S.chars.set(id,c);S.cur=id;S.tab=”begin”;S.confirmDel=false;S.levelUp=null;flush(id);renderAll(); setTimeout(()=>$(“#f-name”)?.focus(),30); } const ACT={ new:newChar, open:el=>{S.cur=el.dataset.id;S.tab=null;S.confirmDel=false;S.levelUp=null;renderAll();ROOT.scrollIntoView({block:”start”})}, close:()=>{S.cur=null;renderAll()}, tab:el=>{S.tab=el.dataset.k;S.confirmDel=false;renderEditor();$(“.tabs .on”)?.scrollIntoView({block:”nearest”,inline:”nearest”})}, rmPortrait:()=>mutate(c=>c.portrait=””), beginRoll:()=>mutate(c=>{c.self={…c.self,stage:”rolling”,method:”app”,fate:2,attempt:1,pending:null};c.rolled={};c.log.push({t:Date.now(),m:”Spent 1 Fate to become a living being.”})}), beginManual:()=>mutate(c=>{c.self.stage=”manual”}), cancelManual:()=>mutate(c=>{c.self.stage=”unborn”}), correctSelf:()=>mutate(c=>{c.self.stage=”manual”}), roll3d6:()=>mutate(c=>{const dice=rollDice(3,6);c.self.pending={dice,total:sum(dice)}}), assign:el=>mutate(c=>{const a=el.dataset.a;if(c.rolled[a]!=null||!c.self.pending)return;c.rolled[a]=c.self.pending.total;c.self.pending=null}), rerollAll:()=>mutate(c=>{const s=c.self;if(s.fate=3)return;s.attempts.push({…c.rolled});s.fate–;s.attempt++;c.rolled={};s.pending=null;c.log.push({t:Date.now(),m:`Spent 1 Fate to reroll the whole character (attempt ${s.attempt}).`})}), keepSelf:()=>mutate(c=>{c.self.stage=”luck”}), rollLuck:()=>mutate(c=>{const dice=rollDice(3,6);c.luck=sum(dice);c.fateLeft=c.self.fate;c.self.stage=”done”;c.luckMoves={}; c.log.push({t:Date.now(),m:`Rolled Luck ${c.luck} (${dice.join(“+”)}). Entered Akralon holding ${c.fateLeft} Fate.`})}), saveManual:()=>{const vals={};for(const a of[…ATTRS,”LUCK”]){const v=parseInt($(“#m-“+a).value,10);if(!(v>=3&&v{ATTRS.forEach(a=>c.rolled[a]=vals[a]);const was=c.self.method;c.luck=vals.LUCK;c.fateLeft=Number($(“#m-fate”).value);c.self.fate=c.fateLeft;c.self.method=was===”app”?”app”:”manual”;c.self.stage=”done”; const moved=sum(Object.values(c.luckMoves||{}));if(c.luck-movedmutate(c=>{c.race=el.dataset.id;c.luckMoves={}}), luckMove:el=>mutate(c=>{const d=derive(c);const a=el.dataset.a,dd=Number(el.dataset.d);const cur=c.luckMoves[a]||0; if(dd>0&&(d.moved>=3||d.luck=ATTR_MAX))return;if(dd<0&&curmutate(c=>{if(c.path!==el.dataset.id){c.path=el.dataset.id;c.cls=””;c.usesMagica=null}}), cls:el=>mutate(c=>{c.cls=el.dataset.id}), toggleMag:el=>mutate(c=>{c.usesMagica=el.checked}), rollPool:el=>mutate(c=>{const d=derive(c);const k=el.dataset.k,i=Number(el.dataset.i);const die=k===”life”?d.lifeDie:d.magDie;const arr=k===”life”?c.lifeRolls:c.magRolls;arr[i]=sum(rollDice(die.n,die.s));for(let j=0;j{mutate(c=>c.skills.push({name:””,kind:”Skill”,cost:””,note:””}));setTimeout(()=>$(`#s-name-${S.chars.get(S.cur).skills.length-1}`)?.focus(),20)}, rmSkill:el=>mutate(c=>c.skills.splice(Number(el.dataset.i),1)), rollSilver:()=>mutate(c=>{const r=rollDice(3,6);c.silverRolls=r;c.silver=sum(r)*10;c.log.push({t:Date.now(),m:`Rolled starting coin: ${c.silver} silver.`})}), togglePay:el=>{S.pay=el.checked}, buy:el=>{const it=ITEM[el.dataset.n];const c=S.chars.get(S.cur);if(!c)return; if(S.pay!==false){if(c.silver==null){toast(“Roll or enter your starting silver first, or untick “Pay from purse”.”);return}if(c.silver{if(S.pay!==false)c.silver-=it.cost;const ex=c.gear.find(g=>g.name===it.name);if(ex)ex.qty++;else c.gear.push({name:it.name,qty:1,equipped:false})});toast(`Added ${it.name}${S.pay!==false?` for ${it.cost}s`:””}.`)}, addCustom:()=>{const v=$(“#custom-item”).value.trim();if(!v)return;mutate(c=>c.gear.push({name:v,qty:1,equipped:false}))}, qty:el=>mutate(c=>{const g=c.gear[Number(el.dataset.i)];g.qty=Math.max(1,g.qty+Number(el.dataset.d))}), rmGear:el=>mutate(c=>c.gear.splice(Number(el.dataset.i),1)), equip:el=>mutate(c=>{const i=Number(el.dataset.i);const g=c.gear[i];const slot=ITEM[g.name]?.slot; if(el.checked&&slot)c.gear.forEach((o,j)=>{if(j!==i&&ITEM[o.name]?.slot===slot)o.equipped=false});g.equipped=el.checked}), fate:el=>mutate(c=>{c.fateSpent=Math.max(0,(c.fateSpent||0)+Number(el.dataset.d))}), hp:el=>mutate(c=>{const d=derive(c);const k=el.dataset.k;const max=k===”life”?d.lifeMax:d.magMax;const key=k===”life”?”lifeNow”:”magNow”; const cur=c[key]??max;let n=cur+Number(el.dataset.d);if(k===”life”)n=Math.min(max,Math.max(-10,n));else n=Math.min(max,Math.max(0,n));c[key]=n===max?null:n}), rest:()=>mutate(c=>{c.lifeNow=null;c.magNow=null;toast(“Full rest: Life and Magica restored.”)}), lvlStart:()=>{S.levelUp={life:null,mag:null,attr:””};renderEditor()}, luCancel:()=>{S.levelUp=null;renderEditor()}, luRoll:el=>{const c=S.chars.get(S.cur);const d=derive(c);const die=el.dataset.k===”life”?d.lifeDie:d.magDie;S.levelUp[el.dataset.k]=sum(rollDice(die.n,die.s));renderEditor()}, luDo:()=>{const lu=S.levelUp;mutate(c=>{const d=derive(c);const to=(c.level||0)+1; c.lifeRolls.length=Math.max(c.lifeRolls.length,to);c.lifeRolls[to]=Number(lu.life); if(d.caster){c.magRolls.length=Math.max(c.magRolls.length,to);c.magRolls[to]=Number(lu.mag)} for(const arr of[c.lifeRolls,c.magRolls])for(let j=0;jmutate(c=>{c.status=el.dataset.v}), exportOne:()=>{const c=S.chars.get(S.cur);exportChars([c],(c.name||”character”).replace(/[^\w\- ]+/g,””).trim().replace(/\s+/g,”_”)||”character”)}, exportAll:()=>{if(!S.chars.size){toast(“No characters to export yet.”);return}exportChars([…S.chars.values()],”Akralon_roster”)}, copy:async()=>{const t=sheetText(S.chars.get(S.cur));try{await navigator.clipboard.writeText(t);toast(“Sheet copied.”)}catch{const ta=document.createElement(“textarea”);ta.value=t;ta.className=”inp”;ta.style.marginTop=”10px”;$(“#editor .panel”).appendChild(ta);ta.select();toast(“Select and copy the text below.”)}}, del:()=>{S.confirmDel=true;renderEditor()}, delNo:()=>{S.confirmDel=false;renderEditor()}, delYes:async()=>{const id=S.cur;clearTimeout(timers[id]);S.chars.delete(id);S.cur=null;S.confirmDel=false;renderAll(); persist();toast(“Character deleted.”)}, }; const MODE_LEVEL={advanced:0,tab1:1,tab2:2}; ROOT.addEventListener(“click”,e=>{const el=e.target.closest(“[data-act]”);if(!el)return;if(el.tagName===”INPUT”&&el.type===”checkbox”)return;const f=ACT[el.dataset.act];if(f){e.preventDefault();f(el)}}); ROOT.addEventListener(“change”,e=>{const el=e.target; if(el.matches(‘input[type=checkbox][data-act]’)){ACT[el.dataset.act]?.(el);return} if(el.dataset.sel){const k=el.dataset.sel;mutate(c=>{c[k]=el.value;if(k===”mode”){const L=MODE_LEVEL[el.value];if(!(c.log||[]).some(l=>/^Reached Level/.test(l.m))){c.level=L;c.xp=Math.max(c.xp||0,XP_TABLE[L]||0);if(L===0&&c.xp{c.skills[Number(el.dataset.i)].kind=el.value},{render:false})} if(el.dataset.roll||el.dataset.fnum)renderAll(); if(el.id===”f-portrait”&&el.files[0])loadPortrait(el.files[0]); if(el.id===”importFile”&&el.files[0]){importFile(el.files[0]);el.value=””} if(el.dataset.lu){S.levelUp[el.dataset.lu]=el.value===””?null:(el.dataset.lu===”attr”?el.value:Number(el.value));renderEditor()} }); ROOT.addEventListener(“input”,e=>{const el=e.target; if(el.id===”search”){S.q=el.value;renderRoster();return} if(el.dataset.f){mutate(c=>{c[el.dataset.f]=el.value},{render:false});if(el.dataset.f===”name”){renderRoster();const h=$(“.ed-head h2″);if(h)h.textContent=el.value||”Unnamed wanderer”}} if(el.dataset.fnum){const v=el.value===””?null:Math.max(0,Number(el.value));mutate(c=>{c[el.dataset.fnum]=v},{render:false})} if(el.dataset.sk&&el.dataset.sk!==”kind”){mutate(c=>{c.skills[Number(el.dataset.i)][el.dataset.sk]=el.value},{render:false})} if(el.dataset.roll){const k=el.dataset.roll,i=Number(el.dataset.i);mutate(c=>{const arr=k===”life”?c.lifeRolls:c.magRolls;arr[i]=el.value===””?null:Number(el.value);for(let j=0;j{setTimeout(()=>{if(S.dirtyRemote&&!document.activeElement?.closest?.(“#editor input,#editor textarea,#editor select”)){S.dirtyRemote=false;renderEditor()}},50)}); function loadPortrait(file){const img=new Image();const url=URL.createObjectURL(file); img.onload=()=>{const max=360;const s=Math.min(1,max/Math.max(img.width,img.height));const cv=document.createElement(“canvas”);cv.width=Math.round(img.width*s);cv.height=Math.round(img.height*s); cv.getContext(“2d”).drawImage(img,0,0,cv.width,cv.height);const data=cv.toDataURL(“image/jpeg”,.82);URL.revokeObjectURL(url);mutate(c=>c.portrait=data)}; img.onerror=()=>toast(“That file couldn’t be read as an image.”);img.src=url} /* ============ FILES (shared format with the website version) ============ */ const FILE_FORMAT=”akralon-character”; async function exportChars(list,base){ const out=list.map(c=>{const x=clone(c);delete x._tab;delete x.createdBy;return x}); const text=JSON.stringify({format:FILE_FORMAT,version:1,rules:”Core Rules v1.25″,exportedAt:new Date().toISOString(),characters:out},null,1); const filename=base+”.akralon.json”; try{await saveFile(filename,text);}catch(e){if(e&&e.code===”declined”)return;toast(“Couldn’t save the file here. Use Copy as text instead.”)} } async function saveFile(filename,text){ if(S.dl){await S.dl.save({filename,data:text});toast(“File saved.”);return} if(window.claude){throw {code:”unavailable”}} const a=document.createElement(“a”);a.href=URL.createObjectURL(new Blob([text],{type:”application/json”}));a.download=filename;document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove()},500);toast(“File downloaded.”); } function importFile(file){ const fr=new FileReader(); fr.onload=()=>{let data;try{data=JSON.parse(fr.result)}catch{toast(“That file isn’t a character file.”);return} const list=data&&data.format===FILE_FORMAT&&Array.isArray(data.characters)?data.characters:null; if(!list||!list.length){toast(“That file isn’t an Akralon character file.”);return} if(S.readonly){toast(“Your access doesn’t allow adding characters.”);return} let n=0,last=null; for(const raw of list){if(!raw||typeof raw!==”object”||!raw.self||!raw.rolled)continue; const c={…blankChar(),…raw,createdBy:S.uid||null}; if(c.race&&!RACE[c.race])c.race=””; const id=”c”+Date.now().toString(36)+Math.random().toString(36).slice(2,6); c.log=[…(c.log||[]),{t:Date.now(),m:”Imported from a character file.”}]; S.chars.set(id,c);flush(id);n++;last=id} if(!n){toast(“No characters found in that file.”);return} if(n===1){S.cur=last;S.tab=null} renderAll();toast(n===1?”Character imported.”:`${n} characters imported.`)}; fr.readAsText(file); } /* ============ BOOT ============ */ function setStatus(){const s=$(“#status”);s.innerHTML=`Saved in this browser`} function boot(){ try{const raw=localStorage.getItem(KEY);if(raw){const o=JSON.parse(raw);for(const[id,c]of Object.entries(o))S.chars.set(id,{…blankChar(),…c})}}catch{} for(const[id,c]of S.chars)if(c.race&&!RACE[c.race])c.race=””; S.ready=true;setStatus();renderAll(); } boot(); })();