// Every tuning value in the project, in one place, read once at boot. // Spec section 7: "Nothing hardcoded at a call site." // // A few of these decide how something FEELS rather than how it behaves, and a // feel gate is worthless if trying a value is expensive. Those are listed in // OVERRIDABLE and can be set from the URL: // // ?down_ms=4000&hits_to_down=2 // // Defaults are unchanged when nothing is passed. // --- the torch light, shared by the demo and the level -------------------- // // THE MASK STOPS LIVE HERE AND NOWHERE ELSE. The renderer builds its gradient // from this table and the reveal test solves its threshold from the same table. // // This matters because the two had already drifted apart once. The original // spec asks for three stops — opaque to 0.65, linear to zero at 1.0 — which puts // 50% opacity at 0.825r. During G3 three more stops were added to kill a visible // ring edge at the falloff, which moves 50% opacity to 0.891r. The light was // signed off with the extra stops in, and nobody noticed the spec no longer // described the shipped gradient. // // Deriving the threshold instead of writing it down means changing the falloff // moves the reveal with it, and there is no constant left to go stale. export const MASK_STOPS = Object.freeze([ [0.00, 1.00], [0.65, 1.00], // spec: full opacity to 65% of the radius [0.78, 0.86], [0.88, 0.55], [0.95, 0.22], [1.00, 0.00], ]); /** Where along the radius the mask first falls below 50% opacity. */ export function halfOpacityPoint(stops) { for (let i = 0; i < stops.length - 1; i++) { const [a, oa] = stops[i]; const [b, ob] = stops[i + 1]; if (oa >= 0.5 && ob <= 0.5 && b > a) return a + (b - a) * ((oa - 0.5) / (oa - ob)); } return 1; } export const REVEAL_T = halfOpacityPoint(MASK_STOPS); export const TORCH = ({ RADIUS: 340, RAISE_MS: 250, LOWER_MS: 400, FLICKER_AMOUNT: 0.04, // +/- 4% FLICKER_HZ: 12, // NOW MEASURED FROM HIM, not from his hand, because the hand moves. It was // 160 ahead of a hand that sat 38 ahead of him, so 198 is the same pool in // the same place -- and the reveal reaches RADIUS + this = 538, which is // what it always reached. The old 500 was 160 + 340, and understated it. LIGHT_AHEAD: 198, // px in the facing direction, from HIM // POSITIVE, i.e. the lit pool sits BELOW the flame. // // The demo spec says -40, putting the circle above the hand. That was written // when the prince held the torch low; re-posing him from the Prince of Persia // reference raised the arm, and the light went up with it. The circle's lowest // point ended up at y=851 with the ground at 880 — the torch did not reach the // ground he was standing on, so nothing on it was ever revealed. // // A flame is the source but the pool it throws is below and ahead of it, so // this is also the truer arrangement. // 80, not 40. At 40 the ground sat at 0.71 of the radius — right where the // mask starts fading — so the pool on the ground was dim and muddy. At 80 it // falls inside the 0.65 full-opacity plateau and actually reads. // Also from him now, and UP is positive. It was +80 below a hand at -110, // so the centre sat 30px above the ground, and it still does. LIGHT_LIFT: 30, // px above the ground line GLOW_RGB: '255,180,90', // #ffb45a GLOW_SPREAD: 1.35, // Raising the torch dims everything outside the circle. This is where the // torch gets its power back without darkening the world it has to be seen // against — and it earns its keep twice, because looking hard at one thing // costing you sight of everything else is exactly what this demo is about. DIM: 0.42, DIM_START: 0.55, // fraction of the radius where the dimming begins DIM_RGB: '4,8,18', // The kept object catches the light. The art brief calls it "the only thing // in the lit version that is fully sharp" — black line work on warm ground // was sharp and still got lost among the body's own lines, so it gets a halo // behind it. It is the payload of the entire looking mechanic; it is allowed // to be the brightest thing inside the circle. // 0.62, not 1.0. At full strength the halo's centre clipped at 255 and the // pulse could only move it 11 levels — a breath you could measure and not // see. Additive light has to stay off the ceiling to have anywhere to go. KEPT_GLOW: 0.62, // The glow does not wait for the torch. Something fell out of a body and the // ground is pure black, so a warm pulse out there is a thing you notice from // across the screen — and it is the only reason to walk over and look. The // torch still earns its keep: it is what turns the glowing dot into a // blacksmith's hammer. Only ever drawn for an object that has been dropped; // one still inside a walking Drained stays black, or the whole premise goes. KEPT_GLOW_DARK: 1.1, // share of the lit halo's strength, out in the dark KEPT_GLOW_R: 1.45, // multiples of the object's half-width. Tighter = hotter KEPT_RGB: '255,214,150', // The object keeps its BLACK line work. Recolouring it pale made it vanish // into the glow — it needs the dark outline to have an edge at all. The // colour goes in the halo behind it instead, so the object sits in its own // hot pool and separates from the body's lines that way. KEPT_RIM: '255,198,110', // And the halo breathes. A steady glow reads as decoration; one that rises // and falls reads as something that came loose and is still there, which is // the whole point of it lying beside the feet rather than on the body. KEPT_PULSE_HZ: 0.55, KEPT_PULSE_DEPTH: 0.55, }) // --- the lightning -------------------------------------------------------- // // PIN A PERSON'S OWN WORDS BACK ON THEIR OWN DOOR AND THE SKY GOES OFF. // // It replaces a silence. A correct pin used to make the same small sound as a // wrong one, and the payoff — a body getting up — happens somewhere else in // the village, possibly off screen, up to a minute later. So the one right act // in the level looked exactly like nothing. // // A FLASH RATHER THAN A SOUND, and that was the right call for two reasons. // Sound on a phone is unreliable and often simply off. And this game is black // shapes against a lit sky: a flash is the one effect that costs nothing to // read, because the whole picture is already built to show a silhouette // against light. For a quarter of a second the sky goes white and you see the // entire street — every shape in it, with no torch. The one moment in this // game you see without looking. // // IT LIGHTS THE WORLD, NOT THE FRAME. Drawn after the ground and the houses // and BEFORE the people, so the sky and the hills go bright and every figure // stays pure black. A wash over the finished frame would lift the silhouettes // too and flatten the only thing that matters. export const FLASH = Object.freeze({ // 560, NOT 900. At 900 the strike itself was right and the last third of it // was a pale veil sitting over the background at 0.05-0.08 for half a second // after the storm had finished — which is not lightning, it is a filter. The // two strikes land in the first 135ms either way; this only cuts the haze. MS: 560, // Near white with the sky's own blue in it, so it reads as weather rather // than as a screen effect. RGB: '226,240,250', // Lightning stutters. One clean spike reads as a camera flash; a spike, a // dip and a second bigger strike reads as a storm. Same table idiom as // MASK_STOPS: [fraction of MS, strength]. // // THE FIRST 60ms ARE DELIBERATELY DARK. The bolt and the flash used to start // together, which meant the bolt spent its opening frames being washed out // by the thing it is supposed to have caused — visible only in the dip, for // about two frames. The sky holds still while the bolt is drawn on it, and // only then goes off. Cause, then effect. STOPS: Object.freeze([ [0.000, 0.00], [0.110, 0.00], // 62ms — the bolt draws on a dark sky [0.160, 0.85], // 90ms — the first stroke [0.250, 0.12], // 140ms — the dark between, and the bolt at its widest [0.340, 1.00], // 190ms — the one that lands [0.500, 0.28], // 280ms [0.720, 0.06], // 403ms [1.000, 0.00], // 560ms — out ]), // THE BOLT ITSELF, eight frames of it, and it is the only thing in this game // that is light rather than a shape blocking light. 25ms a frame puts the // whole strike inside 200ms, so it is drawn, peaked and gone before the // flash has finished — the bolt is the cause and the white sky is the effect. BOLT_FRAME_MS: 25, // How tall, in screen pixels. The source is 120px pixel art; at 480 it reads // as a branched bolt and at anything smaller it disappears into a lit sky. BOLT_H: 480, // Where across the frame. He stands 300px left of a door to pin something, // which puts the house on the right of the view and leaves open sky here. BOLT_AT: 0.2, // THE CRACK, 90ms behind the light. // // It used to be 320 for a far-off rumble. The recording is a CLOSE clap and // it carries its own delay: the front rises over 300ms and peaks 0.40s into // the clip. So 90 here puts the peak at half a second, which is the flash // fading as the sound arrives. Light, then a beat, then it lands. THUNDER_MS: 90, }); /** Flash strength at a moment, off the table. 0 when nothing is happening. */ export function flashAt(ms) { if (!(ms > 0) || ms >= FLASH.MS) return 0; const u = ms / FLASH.MS; const st = FLASH.STOPS; for (let i = 0; i < st.length - 1; i++) { const [a, va] = st[i]; const [b, vb] = st[i + 1]; if (u >= a && u <= b) return va + (vb - va) * ((u - a) / (b - a || 1)); } return 0; } // --- the palette ---------------------------------------------------------- // // THE TWO-SIDED RULE, measured off Blade Warrior rather than guessed at. // // Luminance histogram of a real gameplay frame, in buckets, because exact // colour counts on a lossy video are meaningless — that frame reports 34,653 // distinct colours in what is a 16-colour image: // // under 30 30-90 90-160 over 160 // Blade W. 18.7% 1.1% 39.4% 40.7% // this build 22.9% 30.4% 46.7% 0.0% <- before this change // // 1. NOTHING LIVES BETWEEN 30 AND 90. Blade Warrior goes from black straight to // mid-tone and refuses a band 128 levels wide. The old treeline sat at // luminance 58 and was 23.2% of the frame on its own. That was the mud. // // 2. SOMETHING SUBSTANTIAL HAS TO BE ABOVE 160. The old build had *zero* pixels // up there. Silhouettes only read against light; there was no light. // // A REFINEMENT found while building it: the rule bans the 30-90 gap and // requires a bright mass. It does NOT require every sky value to be bright. // Blade Warrior's own sky spans the boundary — its darker reaches sit in the // 90-160 band, which is why that game reads 39.4% mid against 40.7% bright. A // sky that is uniformly above 160 came out at 63% bright and 16% mid, and read // as washed out rather than as night. // // Every value below is annotated with its luminance so the rule stays checkable // by eye as well as by the standing test. export const PALETTE = Object.freeze({ CLOUD: '#b8d4e0', // lum 205 — the brightest thing on screen SKY_HIGH: '#8aa8c0', // lum 162 SKY_LOW: '#63809c', // lum 122 HILLS: '#4e657e', // lum 97 — near the floor, with a little more margin // The watchers are NOT the prince's black. Standing one beside him at the // same value merged the two into a single mass, which is the one thing the // whole silhouette read depends on not happening. This overrides the Blade // Warrior amendment's "watchers pure black" line, decided by looking. // // There is exactly one place to put them. The hills are already at the floor // of the forbidden zone and the zone owns everything from 30 to 90, so a // value below the hills has to clear 30 from underneath. 26 is as far from // black as the rule allows. WATCHERS: '#131b24', // lum 26 — under the forbidden zone, and not black BLACK: '#000000', }); // WHY THE HILLS ARE AS DARK AS THEY LEGALLY CAN BE, AND NO DARKER. // // 65% of the torch circle sits over the hills band, not the sky — the light is // at chest height and the sky is above it. So the hills are what the torch has // to read against, and at luminance 123 they washed it out. // // Taking them to black fixed the torch and broke the prince. He stands entirely // inside that band, so black hills left him black on black with nothing to read // against. A worse problem than a weak torch. // // He and the light occupy the same stripe of screen, so no single background // value can be bright for him and dark for it. The hills therefore sit at 92, // the darkest the forbidden zone permits, and the rest of the contrast is taken // out of the TORCH instead — see TORCH_DIM below. // How bright the sky is, and where. The brightness is a horizontal band sitting // just above the horizon, not a blob and not a top-to-bottom ramp — the figures // stand against the horizon, so the light belongs behind them. // // PEAK is the one to touch. Raise it for a brighter night, lower it for a // darker one. It feeds a dither between three palette entries, so at 1.0 the // band's centre is solid CLOUD and at 0.5 it never reaches CLOUD at all. export const SKY = Object.freeze({ BASE: 0.11, // the darkest reaches, top of frame and down at the horizon PEAK: 0.72, // height of the band above BASE CENTRE: 0.46, // where the band sits, as a fraction of frame height SPREAD: 0.26, // how far it falls away, as a fraction of frame height }); /** Rec.601 luminance, the same measure the standing test uses. */ export function luminance(hex) { const n = parseInt(hex.slice(1), 16); return 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255); } /** * The same hue at a different luminance. The back lane is the watchers' blue * taken darker, not a grey: a figure beside the road belongs to the same world * as the crowd in the trees, only nearer. */ export function atLuminance(hex, target) { const n = parseInt(hex.slice(1), 16); const k = target / Math.max(1e-6, luminance(hex)); const c = (v) => Math.max(0, Math.min(255, Math.round(v * k))).toString(16).padStart(2, '0'); return '#' + c((n >> 16) & 255) + c((n >> 8) & 255) + c(n & 255); } // --- the level — spec section 7 ------------------------------------------- export const TUNING = { // 430 because the ART says so, not by feel. Her run cycle implies 600 px/s // (a 150px step, twice a cycle, 12fps). Playback is scaled by actual speed // over that, so 220 played the run at 0.37x — a man sprinting in treacle. // 430 gives 0.72x and a 0.70s cycle, which is a run. // // It costs pacing: the level crosses in 49s instead of 105s. The fix for that // is a WALK cycle, not playing a run at a third speed. WALK_SPEED: 430, HITS_TO_DOWN: 3, KNOCKBACK_PX: 160, // constant. Never scales with hit count STAGGER_MS: 500, // The spec called this "the most likely value to be wrong" and it was. At // 8000 a downed Drained was back on its feet almost immediately and simply // resumed the fight, which made putting it down feel like nothing happened. // At 60000 it stays down long enough that you look at it, leave, and may meet // it again much later — which is the beat the level is built on. 8000 made // putting it down feel like nothing had happened; 30000 was still short. DOWN_MS: 60000, RISE_MS: 900, DRAINED_SPEED: 55, // unchanged. It never hurries RUSH_SPEED: 165, // WALKING HOME, and it is its own number because the wander speed cannot // carry it. At 55 a body at one end of the village walking to a house at the // other takes four and a half minutes, and the player has to stand at that // door and wait: filing happens AT the frame, so he is always the one waiting. // // 260 makes the longest walk about a minute, which is time he spends walking // anyway, and the gatekeeper's — 870px, now that the gatehouse stands beside // the gate — about three seconds, on screen, which is what makes him the // tutorial rather than an off-screen event. // // AND IT IS STILL NOT A RUSH. The prince walks at 430 and the rush is 165 at // a sprint cycle; this is 60% of the prince on a walk cycle, so he can always // outpace them and they never read as coming for him. They are not. HOME_SPEED: 260, // HOW LONG THE TORCH CAN SIT ON SOMEONE BEFORE THEY COME FOR YOU, and it is // the number that decides whether looking is a skill or a formality. // // 400 AND IT WAS A FORMALITY. The torch takes about 250ms to get bright // enough to show the page a carrier is holding, and `covers()` starts this // clock the moment the growing circle touches them — so the instant a shape // was readable it had already woken. Measured: the shortest hold that shows // you a shape and the shortest hold that wakes it were the same 0.25s. // // Which left no way at all to tell a carrier from a decoy without fighting // it, so the only strategy was to hit all twelve — and a clean run only has // six hits in it. The loops narrowed twelve to a group and nothing narrowed // a group to a person. // // 800 opens a window of about 550ms: sweep the light across a shape, see // whether it is carrying anything, and drop it before it comes. Looking is // this game's one verb and this is the first place it pays. REVEAL_MS: 800, // THE TWO LANES. Every player who saw a Drained on the road fought it, // because no side scroller lets you walk past an enemy on your own line — // you beat it, jump it or go round it, and no hint overrides a genre. So an // unlit Drained does not stand on his line. It stands beside the road, in a // back lane, and the plane says the state: beside you is harmless, in front // of you is in your way. // // A RENDER TRANSFORM, NOT A WORLD POSITION. Same x, same scroll rate as the // road, no parallax of its own — a lane that drifted against the road would // need reconciling every time something stepped down out of it. // // All three are start values from the spec, to be tuned by eye. BACK_LANE_SCALE: 0.82, // Feet this far above the road's ground line. MEASURED, because the spec // feared it was the thing that broke the mechanic: raising a figure moves // its middle UP, and a torch at hip height would stop reaching it. The torch // is not at hip height any more — the flame is 317px up — so the rise moves // them TOWARD the light. Lit from 339px ahead in the lane against 328 on the // road. BACK_LANE_RISE: 56, // Luminance of the figure's fill. It has to sit between the prince and the // watchers, as far from each as it can get, and under the no-mud band. // // MEASURED, NOT GUESSED, which the spec asked for. 14 was its starting // value, from the palette's 0 for him and 26 for them. On screen he reads 2 // and a watcher reads 41, because the watchers are hazed toward the sky. The // midpoint of what is actually drawn is 21: 19 clear of him, 20 clear of // them. At 14 it was 12 clear of his black and 27 of theirs. See laneReads. BACK_LANE_VALUE: 21, // THE STEP DOWN. Reveal to rush, not hittable, and the only moment a Drained // changes lane on its own. The stare wakes at 800 and this is 400 more, so // it is 1.2s from "I am looking hard" to "it is on the road coming at me" — // the warning the genre expects before an enemy engages. STEP_MS: 400, // THEY WATCH YOU PASS. Walking through one used to produce no reaction at // all, so a player who tried it got no evidence it was safe. Inside NEAR it // turns its whole body to face him and follows him past; outside FAR it goes // back to what it was doing; in between it holds, so it cannot flicker. WATCH_NEAR: 300, WATCH_FAR: 600, // THE STARE SHOWS WHILE IT BUILDS. On his road, a drained taught its own // rule by being in the way; beside it, nothing said these people mattered // or that a long look provokes them. So the light does something at once: // the moment it lands, the figure stops what it was doing and turns to face // it, and for the rest of REVEAL_MS it trembles harder and harder. A glance // shows you a man who noticed; a stare shows you a man about to move. STIR_TREMBLE_PX: 3, // IN THAT ORDER. The gasp and the turn happen the instant the light lands; // the tremble does not start until this share of REVEAL_MS has gone, so the // three read as three beats — it noticed, it is working up to it, it woke — // rather than one blur. STIR_TREMBLE_FROM: 0.35, // THE GATEKEEPER. He never leaves his post, so the fight is entirely about // his timing rather than about position. // // The windup is long and loud because it has to be READ — a large outline // change in flat black, from far enough away to do something about it. The // recovery is the window, and it is measured against the prince's own swing: // 420ms committed, so 700 fits one hit and the step back out, and only just. // // His reach out-ranges the sword on purpose (260 against 175). There is no // safe distance to hit him from; you step inside it and back out again. // A beat of ready between cycles, and only a beat. At 400 he spent a quarter // of every cycle doing nothing and a player could stand at the wall, mash, // and put him down in 1.9 seconds having taken one shove. 120 makes the // cycle 1470ms, which is one hit per cycle and three cycles to down him. POST_READY_MS: 120, POST_WINDUP_MS: 500, POST_THRUST_MS: 150, POST_RECOVER_MS: 700, POST_REACH: 260, // He shoves, he does not kill. A gatekeeper is keeping people away from a // gate, not killing them, and losing ground is the right cost for being late. // // JUST OUT OF REACH, and the number is derived from the window rather than // picked. At 340 the shove put the prince 600px off; closing back to sword // range takes 990ms and the recovery is 700, so even perfect play could never // get back in — he went from beatable by mashing to unbeatable outright, two // hits in forty seconds. // // At 20 he barely moved and mashing won in 1.7 seconds. At 120 the shove // lands the prince 380 out: closing to sword range is 205px (477ms) and the // swing is a committed 420, which is 897 against a 700ms window. So a shove // costs you the rest of that cycle — you land the next hit during his windup // instead, and being shoved is worth about a second and a half. // // WITHOUT HEALTH HE CANNOT BE MADE HARD, ONLY SLOW. The only currency in // this build is time and ground, which is exactly what section 9 of the spec // says and exactly what the V5 stop is for. POST_SHOVE_PX: 120, ATTACK_WINDUP_MS: 120, ATTACK_ACTIVE_MS: 80, ATTACK_RECOVERY_MS: 220, // --- HEALTH, and the reason it is here ------------------------------------ // // Village one played like a beat 'em up, and it played that way because // beating up every Drained cost nothing: three hits, no health, no death. So // the optimal play was to hit everything, and the game agreed. // // The spec's reasoning for leaving health out was wrong, and its author said // so: the question was whether the SEARCH is tedious, and tedium measures // fine at zero risk — but at zero risk there is no search at all, just a // sweep. A sweep cannot be tedious because it is not looking for anything. // // SIX, not three. Three plus a ten-screen restart means one mistake costs // fifteen minutes and people reload the page instead of playing on. Six makes // a death a run of bad decisions rather than one bad moment. // // It does not come back. Six is six for the whole run, and a restart is the // only thing that resets it. PRINCE_HITS: 6, // HOW FAR THE EYE DIMS ON THE LAST HIT. Not to black: a man one hit from the // end still has something in his face, and going fully out is what dying // looks like. // // 0.62 AND THE REASON IS THE FORBIDDEN ZONE. The eye is white over black, so // it lands at 255 x (1 - dim): at 0.8 that is 51 and at 0.67 it is 84, both // inside the 30-to-90 band this whole build refuses to put anything in. 0.62 // lands on 97, just clear of it, and 255 down to 97 is still most of the // range. Dying takes it the rest of the way, and that is a transition rather // than a state. EYE_DIM_MAX: 0.62, HURT_GRACE_MS: 900, // caught once, then a moment to get clear HURT_KNOCK_PX: 260, // NO HEALTH BAR. It is read off his body, the same rule as everything else: // he limps, he holds his side, and the swing slows. // // The limp is HER run frames on an uneven cadence with a dip on the bad side, // not a new drawing. A limp is an asymmetric rhythm, so this is the truer // version as well as the cheaper one. LIMP_FROM: 2, // hits taken before the walk goes uneven LIMP_HITCH: 0.42, // how much slower the bad foot's half of the cycle is LIMP_DIP_PX: 7, // The swing slows as he goes, by hits taken. A cost he can feel in the one // thing he was using to make the cost not matter. SWING_SLOW: [1, 1, 1.1, 1.24, 1.42, 1.66], // HIS ORDERS, AT THE FRONT, AND THEY WAIT FOR HIM. The page is up from the // first frame and stays up until any input puts it down -- normally he // would have to HOLD Q for that, and this is the one time he does not. // // This is an upper bound for anybody who wants one, and 0 means there is // none: it reads at the player's speed rather than at a number's. The // override key is lowercased -- ?order_hold_ms=1 dismisses it on the first // frame, which is how you check the front of the game is otherwise // unchanged. ORDER_HOLD_MS: 0, LOSS_HOLD_MS: 800, // Death restarts the LEVEL. The spec's checkpoint at the mouth keeps the map, // and a checkpoint that keeps the findings is too forgiving to change how // anybody plays. The findings have to be at stake. DEATH_FADE_MS: 1600, // RECOVER_GRACE_MS and RECOVER_PUSH_PX lived here and are gone. They were for // getting up where he fell, which is not what happens any more: being caught // costs one of six, and the sixth restarts the level. See PRINCE_HITS. // How close he has to be to take the object off the ground. The drop sits // 225px out from the body, and he stops about 170px short of it when he // fights, so this makes him walk the last stretch on purpose. PICKUP_REACH: 120, // How close he has to be to a door to read its letter or pin to its slot, // once he has lit it. Wider than PICKUP_REACH because both are up on the // wall rather than at his feet, and wide enough to cover anywhere he could // have lit it from — he reads it standing about 300px off. DOOR_REACH: 440, // How long he has to stand still at a door he has lit before the band reads // it to him again. The first read needs the torch; every read after that is // free, or comparing a door with the page in his hand takes two buttons, and // on a phone both are under one thumb. Standing still is what stops it // opening at every door he walks past — and the wait is what stops it // flashing half open between two swings of the sword. READ_STILL_MS: 300, // HOW LONG A PAPER HAS TO BE UP BEFORE IT IS READ ALOUD. Longer than a page // turn's 260ms beat, so paging through the pile with an arrow held flips // past every page in silence and only the one he stops on speaks. VOICE_DELAY_MS: 400, // THE OPENING'S FIRST PRESS STARTS HIS ORDERS BEING READ rather than putting // them away -- a browser cannot make a sound before a press. If the voice // has not started this long after that press, a press ends it as before. VOICE_WAIT_MS: 2000, // The gate wants four objects he has LOOKED at. Not four objects. GATE_WANTS: 4, GATE_OPEN_MS: 1400, // how long the leaf takes to lift once it does // THE ROAD IS NOW AN APPROACH, NOT THE LEVEL. It was twelve screens, and the // village had to grow to about ten (see BOWL_W), which together is a // hundred-second walk to the gate. One of them had to give and it is this // one: the road's job is to close the canopy over him, teach the fight and // make arriving somewhere land. Four screens does all three. LEVEL_LENGTH_PX: 8000, // about four screens // Three, not five. Five encounters were spread over twelve screens; at four // they land every 0.9 of a screen, which is the same crowding the village // was failing on, in the place that is supposed to be the quiet part. ROAD_ENCOUNTERS: 3, // THE TREE WITH THE NOTE ON IT, as a share of the road. Two-thirds: past // the first drained, so "they" has somebody to mean, and before the first // house, so it is read before the first fight. TREE_AT: 0.66, // AND NOBODY STANDS NEAR IT. Placed first, the way the doors are, and every // road drained kept this far off. Not a guess: he reads it from about 300px // away, and the torch lights a figure up to 339 ahead of him and 214 behind — // so lighting the note can never wake anybody, from either side. TREE_CLEAR: 700, // THE VILLAGE. The road ends and the bowl begins. This replaces the ending // that used to be here — thirty-four watchers stepping onto the path until he // could not get through. That was a placeholder from the first day. // // WIDTH IS THE V1 JUDGEMENT and nobody has measured it. The guess on record // was two and a half screens, which at eleven shapes is one every 435px // against a body 260px wide — the crowd wall again, in a different hat. Five // screens puts them 870px apart and about two on screen at once, so you see a // few and walk to them. Overridable from the address bar, because the whole // point of V1 is to sweep it in one sitting. // // 18000, AND THE NUMBER CAME OUT OF THE TORCH. // // At 9600 every one of the five houses had a Drained inside the radius the // torch reveals at — the forge had one 59px from the door. Holding the torch // freezes him and takes his sword away, so looking at a single door woke // something up with nowhere to back off to. You could not examine one house // in the whole village. // // The arithmetic, which is what set this rather than feel: the torch reveals // out to RADIUS + LIGHT_AHEAD = 538px (it read 500 while LIGHT_AHEAD was // measured from his hand and the hand sat 38 ahead of him; the pool never // moved, only the way it is written down), so a door needs 800px of clear ground // either side to be lookable at. Five houses reserve 8000px. Eleven shapes // want 750px each — the torch is 680 across, so closer than that and one // raise wakes two — which is 8250. Plus margins, about 18000. // // 20000 rather than the 18000 that arithmetic first gave, and the extra came // from measuring the built thing: at 18000 the shapes averaged 1399px apart // and still bunched to 591 in one place, under the 680 the torch is WIDE — // close enough that one raise wakes two. Spacing has to survive the jitter // that stops the street looking like fence posts, so the free ground has to // carry 11 x 900, not 11 x 765. // // At WALK_SPEED 430 this is a 46-second crossing and 140 seconds for three. BOWL_W: 20000, // No shape stands closer than this to a door. It is the whole reason the // village is the size it is, so it is placed FIRST and the shapes go in what // is left over — not scattered and then checked. BOWL_CLEAR: 800, // The forest's watchers thin out in the village, on top of the clearing's own // reduction. Six on screen mid-village plus the shapes read as a crowd, and // this place has been empty for sixty years. The crowd belongs to the trees. BOWL_WATCHERS: 0.45, // Twelve fightable shapes, four of them carrying, and the background watchers // untouched behind them. Two populations, not one: the watchers are what make // it feel like a village and they cost nothing, and the twelve are what you // walk to. One carrier in three is the ratio the search hangs on — if only // carriers did trade loops the loop skill would resolve the bowl instantly. BOWL_CARRIERS: 4, // 3 out in the bowl + the gatekeeper at the gate BOWL_DECOYS: 8, // THE CLEARING, and it is not decoration. depthAt saturates at 1 past the end // of the road, so without this the village sits under a fully closed canopy: // 470px of sky eaten and the watcher layer at maximum density. V4 asks whether // you can read a hammer-shaped bulge in an outline, and an outline needs // something behind it. A village IS a clearing, so this is the right art and // it is also the only way V4 is judgeable at all. BOWL_CANOPY: 0.45, // canopy closure inside the bowl, against 1.0 outside BOWL_OPEN: 0.2, // fraction of the bowl the clearing opens over // Where he starts, as a fraction of the level. Only ever moved from 0 to look // at something: the watchers do not begin until about a quarter in, and // walking there takes a hundred seconds. START_AT: 0, WATCHER_DENSITY_START: 0, WATCHER_DENSITY_END: 10, // Camera: a dead zone across the middle third, no easing, no overshoot. CAMERA_DEAD_ZONE: 1 / 3, // The canopy closes over as he goes deeper, and the SAME curve drives watcher // density. One curve, two systems. The torch stops being optional because the // sky ran out, not because a rule made it required. CANOPY_START: 0.12, // fraction of the level where the canopy first appears CANOPY_FULL: 0.90, // where it has closed over // How much sky it eats at full closure. At 900 it covered everything and the // frame became a letterbox slit; the canopy is meant to close IN, not to be a // black bar. This leaves a band of sky still visible at the deepest point, so // there is something left to lose and the torch has something to beat. CANOPY_MAX_DROP: 470, }; // --- tone: haze and softness by distance ------------------------------------ // Limbo carries depth with atmosphere, not with parallax alone: the further a // layer is, the more it fades toward the background and the softer its edge. // // THE TERRACES. Fields cut into the hillside behind the road, so the back lane // is ground a Drained actually stands on and steps down from, rather than a // height it floats at. // // Three flat steps run the length of the level: the first IS the back lane, // and the third is where the crowd stands. Above them, rows of fields climb // the lower slopes of the hills themselves, following the ridge and // flattening as they come down. Nothing is ever drawn above the hills' // outline — the hills stay the furthest thing, and the fields run up into // them. // // ONE GRADIENT, from muted brown by the road to the hills' own colour. Tried // on the hills' blue first and they read as waves on a lake; tried as their // own hills and they buried the real ones; tried dark and his legs sank into // them. // // AS DARK AS THE NO-MUD RULE ALLOWS, AND NO DARKER. The steps sit behind his // legs, so they are exactly what figure contrast measures: 94 here reads 93 // against its floor of 90. A dark brown at 55 sat better with the road and // failed that check outright — decided against, for now. // // NOT YELLOW. Played through, the light bands between the steps read as // yellow: the field tops were an olive-khaki twenty levels over their walls. // They are the walls' own brown now, only six levels lighter, and each step // gets its edge from a thin dark line underneath instead — the one direction // the no-mud floor leaves room to go. The brown itself moved redder: yellow is // red and green close together, and this pulls green down. export const TERRACE = Object.freeze({ WALL: '#6e584c', // the retaining walls: deep earth brown FIELD: '#705c4c', // the field tops along each edge: the same earth WALL_LUM: 94, SHADOW_LUM: 91, // the line under each edge, right on the floor // Near to far. `above` is px over the back lane's own edge, so the three // move together if BACK_LANE_RISE is tuned. `lip` is how much brighter the // field edge is than its wall; `blend` how far toward the hills' colour. // The third one scrolls at the crowd's own rate, because they stand on it. STEPS: Object.freeze([ Object.freeze({ above: 0, rate: 1.00, wallUp: 0, lip: 6, lipH: 6, blend: 0.00, tufts: true }), Object.freeze({ above: 28, rate: 0.84, wallUp: 2, lip: 6, lipH: 5, blend: 0.14, wobble: 5, seg: 430 }), Object.freeze({ above: 56, rate: null, wallUp: 4, lip: 6, lipH: 5, blend: 0.30 }), ]), // The rows up the hills, as a share of the way from the third step to the // ridge, top row first. Four, and only low down: nine thin lines all the // way to the ridge read as an optical illusion. ROWS: Object.freeze([0.46, 0.34, 0.22, 0.11]), }); // All of this was BANNED until the palette rule changed. Haze puts a layer in // the old forbidden zone by definition, and blurring anything black dragged an // edge from 0 to 163 straight through it. With tone allowed in the background // — actors, trunks and ground still pure black — both are now legal. // // The two things that keep it honest are checks.js: figure contrast (can the // boy be seen) and watcher contrast (can a watcher be seen against the hills). // Hazing the watchers is gated on the second one. export const TONE = { HILLS_HAZE: 0.34, // toward the sky. Lifts 97 and softens the ridge HILLS_BLUR: 7, // measured free: 0.6 points of bright at radius 4 WATCHER_HAZE: 0.12, // gated on watcher contrast, which holds at 66 CANOPY_HAZE: 0, // newly legal CANOPY_BLUR: 10, // newly legal, and free: it sits above the figure band // Newly legal, and the ONE soft cue that is not free. Near trunks cross the // middle of the frame at 1.8x, which is exactly where the prince stands, so // their blurred edge fogs the thing he has to read against. Measured: radius // 8 leaves figure contrast at 92, radius 14 drops it to 75, radius 20 to 65. // // Set to 4 by eye, not by the limit. 8 was where the TEST stopped allowing // more, which is a different question from where it starts looking wrong — // the constraint turned out never to be the binding one. TRUNK_BLUR: 4, TRUNK_WARM: 0.6, // 0..1 toward a warm brown. NEVER lighter, only hued VIGNETTE: 0.55, // newly legal, and the most Limbo-defining of the lot // --- three that are OFF, and are here to be judged by eye ---------------- // // Same deal as the DEPTH block below: built, tunable from the address bar, // and worth nothing until somebody looks at them side by side. // // GROUND MIST. Every figure in this game meets a hard ground line, and a low // mist is the oldest trick there is for separating planes and hiding feet. // Drawn IN FRONT of the actors, because fog behind everybody is a backdrop // and fog in front of them is weather. // ON, at a level where the ground's jagged edge still reads through it. The // first pass was one flat linear gradient and it looked like the sky, // because a smooth even band that never moves is shading, not weather. It is // three banks of overlapping puffs now, drifting at 7, 16 and 34 px/s // against the camera, spaced wider than they are round so the top edge dips // between them. Above about 0.8 the ground line dissolves and it goes back // to being a wash; see drawFog. FOG: 0.6, // 0..1 density FOG_H: 190, // how high it stands off the ground, in px // THE SKY GOING OUT AS HE GOES IN. The canopy already closes on depthAt(); // the sky behind it does not change at all, so a hundred seconds of walking // deeper never looks like deeper. This is how much light the sky loses by // the time the canopy has shut. SKY_SINK: 0, // 0..1 // A LIVER FLAME. TORCH.FLICKER_AMOUNT is 4% at 12Hz, which is a clean circle // with a tremble. Fire is not clean. This adds a slow irregular wander on // top, so the pool breathes rather than buzzes. // // ON. Judged against the flat version side by side and kept. TORCH_LIFE: 1, // 0..1 }; // --- the depth experiment --------------------------------------------------- // ALL OFF BY DEFAULT. Nothing here is a feature yet; see the spec and the table // in docs/FINDINGS.md. Toggled from the address bar, and X cycles them one at a time // because judging these by eye needs fast A/B and typing five URLs is not fast. export const DEPTH = { OCC: 1, // near-layer density multiplier. 1 leaves it alone // ON. Dust in the air, judged side by side against the still version and // kept. Nothing in this game moves except people, and it showed. PART: 300, // total particles on screen, 0 = off PART_HARD: 0, // 1 = integer-aligned rects (no antialiasing, no zone cost) BLUR: 0, // px of blur on the HILLS. Never on the sky, never on black BLUR_SKY: 0, // measured at 12.5 points of bright mass for no depth gained GRAIN: 0, // alpha of the noise tile over the finished frame SHAFT: 0, // 0..1 strength of the light shafts. Judged: no visible gain MOTE: '#dbeef7', // far motes: ABOVE the sky's brightest, so the edge is free }; // --- sound ------------------------------------------------------------------ // A departure: the demo spec says no audio, twice. Taken knowingly. // // There are no sound files, so these are generated the way the art is — rough // placeholders made to be replaced. Volumes only; everything about how a sound // is SHAPED lives in audio.js next to the code that makes it. export const AUDIO = { MASTER: 0.5, FOOT: 0.55, // twice a stride, so it has to sit under everything else // QUIETER THAN HIS OWN FEET, and not by taste. His boots are the thing you // are always hearing; a drag out in the dark is a thing you notice. It also // fires for anything walking within a screen and a half of him, which can be // three bodies at once, so it has to sit well under a single pair of boots. DRAINED_FOOT: 0.34, // WALKING HOME, HEARD FROM OFF SCREEN. Played through a whole level, nobody // could tell the drained were walking home: the flash says something // happened, and the body getting up and walking to its door happens a // screen or four away, silent. So one on its way home is heard wherever it // is — full level within a screen of him, falling to FAR by REACH px away, // and panned to the side it is on. Its door is usually where he is standing, // so what he hears is someone coming toward him. HOME_FOOT_FAR: 0.3, HOME_FOOT_REACH: 3500, // The breath a drained takes when the light first lands on it. Under the // wake, which is the moment it is too late; this is the moment it is not. STIR: 0.5, // ONE PER KIND, and quiet on purpose. Up to three of them are on screen at // once, each firing once a cycle at 6fps over eight frames — so about two a // second between them, for the whole crossing. Loud enough to say where // somebody is, never loud enough to be the thing you are listening to. DRAINED_VOICE: 0.40, // The torch coming up. It fires every time he raises it, which is constantly, // so it has to sit under his own boots. TORCH: 0.30, // The instant a shape notices him. Louder than the voices on purpose: it is // the moment looking costs him, and it happens about twelve times a run. WAKE: 0.62, // The village under everything. Quiet enough that you notice it when it // stops, which is what a bed is for. VILLAGE: 0.30, SWING: 0.7, HIT: 0.85, FALL: 0.9, // Quiet on purpose. A page turn fires while he is reading, several times in a // few seconds, where every other sound in this game fires once. PAGE: 0.45, // Louder than a page turn, quieter than a hit. It fires four or five times in // a whole level and it is the sound of him deciding something. MARK: 0.7, // Far off. It fires four times in a level and it is the sky, not an alarm — // and the flash has already said everything it says, so this is the part you // feel rather than the part you are told. THUNDER: 0.55, // THE VOICES. Every paper is read aloud by whoever wrote it. Each recording // is measured when it loads and brought to VOICE_LUFS -- the takes came in // 14 dB apart -- and VOICE is the level of all of them on top of that. // -23 is where the loudest take still has 1.5 dB of room above it. VOICE: 1.0, VOICE_LUFS: -23, // What the bed and the torch drop to while somebody is speaking. The words // are the evidence; the crackle is only the room they are read in. VOICE_DUCK: 0.45, }; // --- the fifteen-second demo, kept runnable at ?mode=demo ----------------- // Its own numbers, deliberately separate, so tuning the level cannot silently // change the thing that is deployed and being played. export const DEMO = Object.freeze({ DRAINED_SPEED: 55, DRAINED_RUSH_SPEED: 135, DRAINED_RUSH_ACCEL: 190, KNOCKBACK_SPEED: 850, KNOCKBACK_DECEL: 2200, STUN_TIME: 0.42, PRINCE_RUN_SPEED: 420, PRINCE_STOP_X: 700, CONTACT_GAP: -40, CONTACT_HOLD: 0.8, FADE_TIME: 0.4, }); // --- URL overrides --------------------------------------------------------- // Only the values a feel decision hangs on. Everything else stays fixed, so a // stray query string cannot quietly change how the level behaves. const OVERRIDABLE = new Set([ 'DOWN_MS', 'RISE_MS', 'STAGGER_MS', 'HITS_TO_DOWN', 'WALK_SPEED', 'DRAINED_SPEED', 'RUSH_SPEED', 'HOME_SPEED', 'REVEAL_MS', 'START_AT', 'ORDER_HOLD_MS', // Letters stay on the door. How long standing still takes to read one again. 'READ_STILL_MS', // The voices: how long a paper is up before it speaks, and how long the // opening waits for its voice before a press ends it anyway. 'VOICE_DELAY_MS', 'VOICE_WAIT_MS', // The two lanes. Every one of these is a start value that gets tuned by eye. 'BACK_LANE_SCALE', 'BACK_LANE_RISE', 'BACK_LANE_VALUE', 'STEP_MS', 'WATCH_NEAR', 'WATCH_FAR', 'STIR_TREMBLE_PX', 'STIR_TREMBLE_FROM', // The V1 judgement. Sweeping it has to be a query string, not an edit. 'BOWL_W', 'BOWL_CANOPY', 'BOWL_CLEAR', 'BOWL_WATCHERS', 'ROAD_ENCOUNTERS', ]); // How loud the dropped object is out in the dark, and how hard it breathes. // These are looked at, not reasoned about, so they get the same treatment. const OVERRIDABLE_TORCH = new Set([ 'KEPT_GLOW_DARK', 'KEPT_GLOW', 'KEPT_GLOW_R', 'KEPT_PULSE_HZ', 'KEPT_PULSE_DEPTH', ]); /** Mixing is done by ear, so the mix is tunable from the address bar too. */ const OVERRIDABLE_AUDIO = new Set(['MASTER', 'FOOT', 'SWING', 'HIT', 'FALL', 'DRAINED_FOOT', 'THUNDER', 'DRAINED_VOICE', 'TORCH', 'WAKE', 'VILLAGE', 'HOME_FOOT_FAR', 'HOME_FOOT_REACH', 'STIR', 'VOICE', 'VOICE_LUFS', 'VOICE_DUCK']); /** The experiment's five cues, plus the particle draw mode. */ const OVERRIDABLE_TONE = new Set(['HILLS_HAZE', 'HILLS_BLUR', 'WATCHER_HAZE', 'CANOPY_HAZE', 'CANOPY_BLUR', 'TRUNK_BLUR', 'TRUNK_WARM', 'VIGNETTE', 'FOG', 'FOG_H', 'SKY_SINK', 'TORCH_LIFE']); const OVERRIDABLE_DEPTH = new Set(['OCC', 'PART', 'PART_HARD', 'BLUR', 'BLUR_SKY', 'GRAIN', 'SHAFT']); export const overrides = {}; if (typeof location !== 'undefined') { const params = new URLSearchParams(location.search); for (const key of OVERRIDABLE) { const raw = params.get(key.toLowerCase()); if (raw === null) continue; const value = Number(raw); if (!Number.isFinite(value) || value < 0) continue; overrides[key] = value; TUNING[key] = value; } for (const key of OVERRIDABLE_TONE) { const raw = params.get(key.toLowerCase()); if (raw === null) continue; const value = Number(raw); if (!Number.isFinite(value) || value < 0) continue; overrides['TONE_' + key] = value; TONE[key] = value; } for (const key of OVERRIDABLE_DEPTH) { const raw = params.get(key.toLowerCase()); if (raw === null) continue; const value = Number(raw); if (!Number.isFinite(value) || value < 0) continue; overrides['DEPTH_' + key] = value; DEPTH[key] = value; } for (const key of OVERRIDABLE_AUDIO) { const raw = params.get('audio_' + key.toLowerCase()); if (raw === null) continue; const value = Number(raw); if (!Number.isFinite(value) || value < 0) continue; overrides['AUDIO_' + key] = value; AUDIO[key] = value; } for (const key of OVERRIDABLE_TORCH) { const raw = params.get(key.toLowerCase()); if (raw === null) continue; const value = Number(raw); if (!Number.isFinite(value) || value < 0) continue; overrides[key] = value; TORCH[key] = value; } } // --- where the village is --------------------------------------------------- // One world axis, no second scene. The mouth is a world x where the camera // stops following and starts being bounded, and that is the entire transition — // no loading, no fade, no gate. The houses are the only signal it happened. // // Derived here, AFTER the URL overrides have landed, so ?bowl_w= cannot leave // the mouth and the gate disagreeing about where the village is. export const BOWL = Object.freeze({ X0: TUNING.LEVEL_LENGTH_PX, X1: TUNING.LEVEL_LENGTH_PX + TUNING.BOWL_W, }); /** * Where a run begins. * * ?at=bowl and ?at=gate exist because every gate from here on is judged on * something at the far end of the level, and walking fifty-three seconds to * look at it once is how a sweep stops happening. START_AT stays a fraction of * the road for everything before the village. */ /** * THE RUN SEED, and it is not the world seed. * * The world has been deterministic from a hashed position since the first spec * and every check depends on it: the slots, the clear pockets round each door, * the spacing floors. None of that may move. * * What varies per run is which ROLE stands in which slot and which body carries * which fragment. So the placement maths never sees this number, and every * guarantee V1 bought survives untouched while position memory stops working — * four friends comparing notes cannot say "the blacksmith is third from left". * * It buys that and not a second puzzle. A player who remembers the forge letter * subverts has spent the deduction; the shuffle makes them fight again, not * think again. * * ?seed= fixes it, which is what every check uses. */ export function runSeed() { const q = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('seed') : null; const n = q === null ? NaN : Number(q); return Number.isFinite(n) ? (n >>> 0) : (Date.now() >>> 0); } /** Deterministic 0..1 stream from a seed. Same seed, same run. */ export function mulberry32(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export function startX() { const at = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('at') : null; if (at === 'bowl') return BOWL.X0 + 400; if (at === 'gate') return BOWL.X1 - 1600; if (TUNING.START_AT > 0) { return Math.round(TUNING.LEVEL_LENGTH_PX * Math.min(TUNING.START_AT, 0.98)); } return 300; } /** * 0 at the start of the level, 1 once the canopy has closed — and back down * again inside the village, which is a clearing. * * The road's curve is untouched, so the bright-mass check keeps its shape and * its numbers. The bowl opens over its first fifth, holds, and closes again at * the gate, so walking in is walking into light and walking out is not. */ export function depthAt(worldX) { const t = (worldX / TUNING.LEVEL_LENGTH_PX - TUNING.CANOPY_START) / (TUNING.CANOPY_FULL - TUNING.CANOPY_START); const c = t < 0 ? 0 : t > 1 ? 1 : t; const road = c * c * (3 - 2 * c); // eased, so the close creeps rather than ramps if (worldX <= BOWL.X0) return road; const u = Math.min(1, (worldX - BOWL.X0) / TUNING.BOWL_W); const k = TUNING.BOWL_OPEN; const o = Math.max(0, Math.min(1, u / k, (1 - u) / k)); const e = o * o * (3 - 2 * o); return road - (road - TUNING.BOWL_CANOPY) * e; } Object.freeze(TUNING); Object.freeze(TORCH); Object.freeze(AUDIO); // DEPTH is deliberately NOT frozen. The cycle key writes to it.