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.
Generate Self: 3d6 per attribute, assigned as rolled, then Luck
Choose a Race and drain Luck if you dare
Choose your Path and Class
Derive Life, Magica, To Hit and AC
Spend your skill points
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.
${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.
${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."}
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.
${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.
Attribute
Rolled
Race
From Luck
Final
Mod
${ATTRS.map(a=>`
${ATTR_NAME[a]}
${d.base[a]}
${sgz(d.rm[a])}
${c.luckMoves[a]||0}
${d.fin[a]}
${sgz(d.M[a])}
`).join(“”)}
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.”}
${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+=`
`}
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).
${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.
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)}`:””}