vibespire.ai / Bearer /

how it's made

start walking โ†—

Vibespire presents

How the Bearer Is Made

The look of this thing was not eyeballed. Every quantity in it was pulled numerically out of another game's published screenshots before a line of shader was written, and three of those measurements came back saying the opposite of what the screenshots plainly say: the halftone grid is not the JPEG grid, the palette is dead neutral grey, and there is no film grain in it at all. The one that mattered most โ€” that the soft parts of a frame are a real defocus rather than a dither being switched off โ€” is the game's only mechanic.

CONTINUOUS TONE SIX LEVELS ONE SHARP BAND

The two figures below run the game's own Screen class, its own screen.frag and its own ramp.

The screen ยท src/screen/screen.frag

One pass turns a photograph into a print

Bearer draws its world with an ordinary 2D canvas โ€” sprites, terrain blits, text, the lot โ€” into a 512ร—512 greyscale buffer, and paints a second, much smaller canvas, 128ร—128, with where the world is in focus. Those two texture uploads and one full-screen triangle are the entire GPU workload. Everything in the project that looks like a decision about art is made in the fragment shader that runs over them.

The function that does it is printed(), and it does five things in a fixed order. It samples the scene buffer at a texel centre โ€” floor(uv ร— 512) + 0.5, so tone is read on the buffer's own lattice and never interpolated. It applies lift and exposure. It stretches contrast about uPivot rather than about mid grey, because the pivot has to follow the light: pulling a dark frame toward 0.5 makes it grey rather than dim, which is exactly what the first night looked like. It adds a trace of noise, then multiplies by the vignette โ€” before quantisation, so a darkened pixel is pushed down the ramp rather than tinted off it. Only then does it screen the result into dots.

There are two lattices, and they are different sizes on purpose. Tone is sampled and quantised on the scene buffer's own grid, uScenePx = 512. The screen โ€” the dot lattice โ€” is evaluated on a finer one, uGrid, which is 1024 in the shipped defaults. That is four lattice units to a scene texel, two per axis, which is what lets a texel carry a dot with an edge in it instead of being one. Across the square there are uCells = 128 halftone cells, so a cell covers sixteen scene texels, four per axis.

The fine lattice is capped at whatever the display can actually draw. Screen.resize sets it to floor(size / 128) ร— 128, clamped between 512 and 1024: a 1024-unit dot grid rendered into 640 output pixels beats against the pixel grid and turns into moirรฉ. Keeping it a multiple of the cell count guarantees a cell is always a whole number of lattice units, which is what keeps the dot grid even, and evenness is most of the illusion.

The corner cut runs on that same fine lattice, so the rounded corners step rather than feather. corner: 0.0222 of the square is the 24 pixels the reference's corners measure at 1080. A feathered corner would be an anti-aliased edge in a picture that contains no other anti-aliased edges.

The real Screen class from src/screen/gl.ts, running the real screen.frag over a scene drawn into its own sctx with the game's own fonts. Every slider writes into screen.params โ€” the same object applyLook writes into on every frame of the game.

The palette, the tint and the corner cut all happen once, afterwards, on the finished tone โ€” because printed() is called up to nine times per fragment, and everything that only has to happen once was moved out of it.

The halftone

The dot is a diamond, and the cross is the paper

Look at a mid tone in the reference and you see plus signs and diagonal crosses, and the natural conclusion is that the dot is cross-shaped. The extracted matrix says the opposite: ink starts at the centre of a cell and the last part to fill is its corners, so the crosses are the paper between four neighbouring dots. The first implementation used a diagonal cross matrix on the strength of that misreading, and it tiled into a checkerboard.

What ships is five lines:

vec2  pd   = floor(uv * uGrid) + 0.5;
float cell = uGrid / uCells;
vec2  f    = fract(pd / cell) - 0.5;
float dd   = abs(f.x) + abs(f.y);
float thr  = dd <= 0.5 ? 2.0*dd*dd : 1.0 - 2.0*(1.0-dd)*(1.0-dd);

|x|+|y| is a diamond distance, so the threshold contours are nested diamonds growing from the cell centre. The two branches are the second half, and they are the half that matters. A diamond's area is not proportional to that distance โ€” a fifth of a cell sits below 0.32 โ€” so handing the raw distance in as a threshold bends the tone curve, and dark tones land lighter than they were asked to be. The branches are the diamond's own area function inverted, which puts a half tone at half coverage. What a cell cannot do is be exact on its own: at the shipped 1024/128 it is eight lattice units across, so it has sixty-four units to spend, and the tone in between is carried by the sixteen scene texels underneath it landing on different sides of the threshold.

One cell of the screen, magnified, and the same cell tiled twenty-five times. The cell size comes from GRID / CELLS in src/screen/gl.ts and the ink and paper are the first and fifth entries of RAMP_HEX; the threshold is screen.frag's own expression, transcribed, because GLSL cannot be imported. The shaded region on the right is the paper four dots leave between them โ€” the plus you can see in a screenshot of the reference.

Which shape, though? tools/measure/reference.mjs rolls the extracted matrix so its first-inked cell is central and correlates it against four candidates. All four come back positive, because the loudest thing the matrix says is that ink grows from the middle โ€” but the ranking is clear enough to follow: the diamond |dx|+|dy| at +0.798, the disc at +0.765, the cross at +0.709 and the square last at +0.655. Note where the cross comes: third, despite being the shape you can actually see. That is the same misreading, caught by the same number.

A square shipped here for an afternoon, on the strength of a written description of the reference rather than the correlation, and the correlation puts it last of the four. Both are equalised and both quantise the same way, so the tone histogram did not move by a tenth of a per cent between them โ€” which is why it took running the script against the shipped value to catch it.

The plus you can see in a screenshot is not a mark on the page. It is the part of the page nobody printed.

Measurement ยท tools/measure, test/compare

You cannot answer "does it look right" by staring at it

node tools/measure/reference.mjs fetches eight published store screenshots of O EMPIRE! WARD OFF THY ROT, by Dean Moynihan, decodes them in a headless Chromium and extracts the seven quantities the screen shader needs. It is committed, and every figure in screen.frag's header comment is a line of its output.

It measures the frame first, because everything downstream is expressed in that coordinate system: the picture is a 1080ร—1080 square pillarboxed at x = 420, and all eight screenshots agree to the pixel. 512 is the buffer size that divides 1080 exactly โ€” 2.109375 โ€” which is why Bearer's is 512 and why its upscale is nearest-neighbour rather than filtered.

One: the halftone period is 8.4375 pixels, which is 1080/128. The obvious objection is that 8.4375 is close to 8, and 8 is JPEG's block size, so what is being measured might be the codec. The sweep settles it. Bin every pixel by its position within a candidate cell, subtract the local mean, average, and take the standard deviation of the resulting matrix โ€” if a dither grid of that period is really there, sub-cells that take ink early come out darker than their surroundings and the matrix has structure.

โ”€โ”€ the halftone period โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
   8.0000 px   structure 0.30
   8.1000 px   structure 0.15
   8.2000 px   structure 0.17
   8.3000 px   structure 0.32
   8.4375 px   structure 1.33  โ†
   8.5000 px   structure 1.05
   8.6000 px   structure 0.36
   9.0000 px   structure 0.14

  1080 / 8.4375 = 128.0 cells across the square

At JPEG's own 8.0 the score is 0.30, less than a quarter of the winner's 1.33, and both neighbouring periods are lower still. The grid belongs to the game, so CELLS = 128.

Two: the ramp is dead neutral grey. Every screenshot reads as a sepia photocopy โ€” mauve shadows, warm paper โ€” and none of that is in the palette. Sample only pixels lying along one diagonal band, so screen position is held roughly constant; bin them by luminance; look at Rโˆ’G. Across twenty-six bins it averages 0.12 and never passes 0.45. What does move is Gโˆ’B, and only at the top: about zero through the mid tones, then climbing to 22.9 in the brightest bin. That is why the top of the ramp is cream and nothing else is.

The warmth is a fixed diagonal gradient in the red channel laid over the finished picture, which in screen.frag is three lines at the end:

float d = 0.8 * vUv.x - 0.6 * vUv.y + 0.3;
col.r += uTint * d;
col.b -= uTint * d * 0.55;

(0.8, โˆ’0.6) is a unit vector, so d is a plain signed distance along one diagonal, and the shipped tint is 0.05. Baking the warmth into the palette instead was the first version and it turned the shadows purple, which they are not. The ramp itself is six steps from ink 53 to paper 230, with only the top one drifting to cream: #353536 #575758 #79797a #9b9b9a #bebeb9 #e6e3cb. There is no night palette. Night is the same six with the exposure slid down them.

Three: there is no grain. High-pass the flattest region available with a Laplacian and what survives is 0.37 of 255 โ€” 0.14% RMS โ€” and it rises with tone the way residual dither does and noise does not. It is JPEG. What looks like film grain in a screenshot of the reference is the compression. Bearer's first build shipped 4.5%, thirty times that; what ships now is grain: 0.012 peak to peak, about 0.35% RMS. That is above the reference's floor rather than below it, and deliberately so: a generated heightfield bands where a hand-built one does not, and a trace of noise is what breaks the bands.

A fourth measurement was less surprising and had to be got exactly right anyway. The vignette is corners only, and it is later and steeper than it looks. Binning the 90th-percentile tone by r = |uvยท2โˆ’1| โ€” 1.0 at the midpoint of an edge, 1.414 at a corner โ€” gives a profile that is flat out to r = 1.24 and then falls off a cliff.

  1.20   0.958
  1.24   0.947
  1.28   0.909
  1.32   0.706
  1.36   0.460

Bearer ships vignette: 5.6, vigStart: 1.26, fitted to those four points. The bin at 1.40 is left out of the fit because the rounded-corner mask is already eating it. A vignette that starts at the edge instead โ€” the default assumption, and what a vignette shader normally does โ€” puts a grey haze along all four sides, and the reference's sides measure within 6% of the flat value the whole way out to r = 1.24.

r = 1.0 r = 1.414 r = 1.24 ยท 0.947 shaded: r > 1.26, the only part that darkens
The falloff geometry from screen.frag, drawn to scale. The circle is vigStart = 1.26; the whole middle of every edge falls inside it, and only the four corner caps darken.

Then the measurement is turned around. The thing being copied is a distribution โ€” how much of a frame sits on each of the six tone levels โ€” and that is measurable on both sides. test/compare.mjs starts a Vite server, drives a real browser to a real seed, walks it east for a given number of seconds, clusters every pixel of the canvas onto the six ramp luminances and prints the histogram beside the same measurement taken from six of the reference's own screenshots. Below is node test/compare.mjs ash 30. Anything below the darkest level is excluded, because the corner cut multiplies toward black and that is frame, not picture.

tone levels, dark โ†’ light

  ss3 karst, day       14.9%  30.1%  24.7%  19.2%  10.1%   1.1%   mean 114
  ss5 village, day     20.2%  27.5%  16.1%  20.1%  14.1%   2.0%   mean 117
  ss1 moor, bright      6.0%   5.1%  11.2%  33.2%  38.1%   6.3%   mean 159
  ss7 tower, bright     2.5%  10.8%  20.3%  37.1%  27.6%   1.7%   mean 149
  ss6 map, night       60.0%   9.5%  11.4%  10.6%   6.1%   2.4%   mean 87
  ss4 moon, night      87.4%   5.4%   3.0%   1.1%   0.2%   2.8%   mean 63
  ------------------------------------------------------------------
  bearer (ash)         19.0%  28.7%  15.0%  21.8%  15.0%   0.5%   mean 122

  daylight 0.91 ยท exposure 0.803 ยท lift 0.012 ยท vignette 5.639
  closest reference frame: ss5 village, day  (3.8% of the frame would have to move)

One number falls out of it: the total variation distance to the nearest reference frame. 3.8% of this frame would have to change tone to become the reference's village at midday. The exposure and contrast curves in applyLook were set against this harness, and the harness drives them from outside the build โ€” window.bearer.tune multiplies exposure, contrast and vignette without a rebuild between guesses, so a forty-eight candidate sweep is one browser and one world.

The histogram also shows where Bearer is not the reference. The brightest level is the stubborn one: 0.5% here against 2.0% there, and it took a pivoted contrast curve to get that close, because a generated heightfield lit by one sun has no specular anything. The camera scale was the other thing eyeballing got wrong. In the reference's village shot a timber cottage spans about 160 of the square's 1080 pixels, which at seven metres of cottage is 10.6 buffer pixels to the metre; the first build used 6, derived from the size of the figure instead, and every village in it looked like a model railway. PPM ships at 10.

Three afternoons of arithmetic, and three things that had already been built the other way round.

Focus ยท the light field

The soft areas are a real defocus, and that is the mechanic

In the reference, part of every frame is a crisp halftone and the rest is flat posterised shapes. The cheap explanation is that the dither is only applied in the sharp part. Two measurements say otherwise.

The first is sharpness: for each band of rows, the median ratio of a one-pixel first difference to a three-pixel one. A sharp edge changes as fast over one pixel as over three, so the ratio is high; a blurred edge does not. It runs from 0.36 in the soft rows to 0.55 in the band. The second is the carrier: the strength of the 8.4375-pixel periodic signal in the same rows, as a fraction of the local contrast. It goes with it โ€” 0.24 to 0.66 โ€” and it never reaches zero. The dots are still there in the soft rows, smeared. That is what a defocus does to a print, and it is not what switching the dither off would do.

So printed() โ€” sample, quantise, screen โ€” is written as a function that can be called at an offset, and the main body calls it nine times in two rings whenever the blur radius clears a threshold: the centre at weight 0.28, four taps at ฯƒ weighted 0.12, four diagonal taps at 2ฯƒ weighted 0.06, summing to one. Two rings rather than a box of nine, because a box leaves the dot lattice visible as a beat pattern; two rings put the kernel's zeros near the carrier and kill it. The result is then interpolated between ramp entries rather than snapped to one, because a defocused print really does land between two levels.

Which leaves where the sharp band is, and that is the game. Composer.lightField paints the 128ร—128 focus canvas each frame: a floor of round(day ร— 96) out of 255 over the whole square, then a blob of 26-metre radius on the bearer and a disc around anything burning, drawn under lighter. The shader adds its own floor โ€” ambient = 0.14 + day ร— 0.26, a different quantity โ€” reads the field, and sets the blur radius to (1 โˆ’ smoothstep(0.12, 0.92, sharp)) ร— uBlur.

It gives the day cycle away for free. At noon the floor is high, the frame is dithered nearly everywhere, and the flat shapes are pushed out to the corners. At three in the morning the floor is near zero and the field collapses to whatever you are carrying a light for. There is no second set of art anywhere in the project; the whole cycle is exposure, lift, contrast and this floor.

Walking is the act of bringing the world into focus.

The ground ยท src/world

Albedo, form and texture are three separate numbers

A pixel of ground gets its tone from three things, and the version of this that works keeps them apart: how bright the material is, how it is lit, and what it is made of. Three multiplications and three owners.

Albedo is a table in src/world/surfaces.ts: twelve numbers, one per surface, spread deliberately wide because six tone levels is not many and a world where canopy and scree sit within 0.1 of each other spends four of them on nothing. Canopy is 0.30, the darkest thing in the world that is not a silhouette; old snow is 0.94. Form is the hillshade, computed in chunks.ts on a 65ร—65 lattice per 256-pixel chunk โ€” one node every four buffer pixels, bilinear in between, because the shape of land is low-frequency. Texture is the generated aerial plate, and it only ever modulates: every plate is normalised to a flat mean of 0.5 and enters as 0.75 + tex ร— 0.5.

The first version had brightness baked into the photographs โ€” each plate normalised to its own mean, so "how bright is snow" was a number in the art pipeline. Every change to the sun broke a biome. With the three separated, moving the sun changes one term and nothing else has to be touched.

Two details in the hillshade are worth the space. The Lambert term is quantised โ€” round(max(0, ฮป) ร— 6) / 6 โ€” because the reference's terrain is flat-shaded polygons whose facet interiors are one constant tone, and a smooth Lambert over a smooth heightfield gives the opposite: an unbroken gradient. And the gradient is exaggerated by 4.5 before it is lit, because a true hillshade of this continent is nearly featureless โ€” the moor is an 11% grade at its worst โ€” and a nearly featureless hillshade quantises to one grey. Vertical exaggeration before shading is ordinary practice in relief cartography; the Swiss manual tradition Eduard Imhof wrote up does it by hand.

Then there is the micro-relief. The heightfield is two metres to the texel, which at ten pixels to the metre means the finest shape it can hold is twenty pixels across. But the plate is a photograph of ground, so its own local slope is a height gradient already: two extra array reads give it, and lighting that slope with the same sun gives per-pixel form at the plate's frequency for the cost of the reads.

const bx = (texR - tex) / 255, by = (texD - tex) / 255;
const micro = 1 + (bx * 0.62 + by * 0.55) * BUMP;

0.62 and 0.55 are the sun's own x and y. It applies where a chunk is covered by a single surface; where two are blending, the two extra reads would have to be done per surface and the term falls back to 1.

Chunks are baked at 256 buffer pixels and cached โ€” a few milliseconds once, then a drawImage forever, with an LRU of 96 and a budget of two bakes a frame. The camera never travels more than a pixel or two between frames, so a chunk that misses its turn is off screen by the time it would have mattered.

Roads and rivers are stroked into the chunk in world space rather than evaluated per pixel: a distance field on the low-resolution lattice would put a road edge on a four-pixel staircase, and the road is the one thing in the frame the player is actually following. The road goes down under lighter and the river under source-over, because a road is the brightest line on the moor and a river is the darkest.

Everything nobody planted is not stored at all. The world is cut into 14-metre cells and each cell's contents come out of a hash of its coordinates, so a query for "what is in this rectangle" is a loop over a few hundred cells and no memory. Walk east for a kilometre and back and the same tree is still there, because it was never anywhere.

The art ยท tools/art

Never ask the model for the look

Seventy assets are specified in tools/art/manifest.mjs and generated with gpt-image-2 through fal.ai. The model is never asked for the look. The only place any of it is mentioned is as a negation: the shared NO_STYLE fragment, concatenated into every prompt, says "not pixel art, not vector art" and "no grain filter". The file's own header states the rule โ€” every asset is generated as an ordinary continuous-tone photograph, in colour, at high resolution, and the screen shader does the rest.

Ask for "1-bit dithered top-down pixel art" and you get a picture of dithering โ€” a drawing whose dots are content โ€” and running a real dither over that produces interference with no legible form left in it. Ask for "an aerial photograph of moorland, overcast" and you get material with genuine tonal structure, which the screen shader turns into the look in one pass. The model's job is tone and form.

Four shared fragments assemble every prompt: NO_STYLE, PLATE for terrain, OBJ for anything that stands up and ICON for the item pictures. They are opinionated about lighting for a reason. Plates are asked for flat overcast light, no sun, no cast shadows โ€” because the terrain's form comes from the engine's own hillshade, and if the photograph brings its own sun the photograph's shadows fall one way and the hillshade's fall the other. Objects get the opposite: hard directional sunlight from the upper left, camera 70ยฐ above horizontal, because their form is all they have. They are drawn a couple of dozen pixels tall and the only thing that says a cottage is a cottage is the difference between its lit roof plane and its shaded one.

1 ยท matte

Objects are shot on flat magenta. Alpha comes from distance to pure magenta, ramped between 0.16 and 0.40 rather than thresholded, so the anti-aliased rim survives the downsample as partial coverage. Despill acts on the magenta region โ€” anywhere red and blue both sit above green โ€” which is somewhere a brown, a grey or a green never is.

2 ยท downsample

Repeated halving in a canvas rather than one draw. A single draw from 1024 to 30 pixels samples far too sparsely and loses whole branches; halving keeps them as tone.

3 ยท seamless

Plates only. Roll the tile by half in both axes and blend the two copies through a mask that is 1 at the border and 0 at the centre, so the rolled copy's own seam lands where the mask has already zeroed it. A four-way mirror was the first version; scree gave it away immediately.

4 ยท tone

Colour is thrown away. Luminance at 0.32/0.55/0.13 โ€” Rec.709 with green pulled down, because foliage is the commonest subject here and true 709 sends every conifer to the same value โ€” then normalised between the 2nd and 98th percentile to a stated mean and contrast.

What is committed is a single channel of tone. An asset that brought its own colour would be the one thing in the frame that is off-ramp. Eight of the vegetation sprites go further and keep only their shape โ€” ink: true forces every opaque pixel to the bottom of the ramp โ€” because the reference's conifers and figures are solid black with no interior tone at all.

Two things were deliberately not generated. Cast shadows are baked at load by shearing each sprite's own alpha away from the sun, flattening it to one tone and blurring it; thirty generated shadows would have been thirty chances for one of them to disagree about where the sun is. And the figures are drawn by hand as character grids in src/draw/figures.ts, because the bearer is fourteen pixels tall and there is nothing for a photograph to be at fourteen pixels.

The run produced 65 of the 70. The fal.ai account ran out of credit with relic, i-candle, i-herb, i-boot and i-icon outstanding, and the engine draws documented fallbacks for all five: a missing item icon is a plain filled square in the hand screen, a missing sprite a black lozenge of the right height, so the composition, the sorting and the shadows stay honest. The burden โ€” the thing the whole walk is about โ€” is a grey square in your pack.

Two screens ยท src/ui

Your inventory is a photograph of your own hand

The inventory is not a grid of boxes. It is a generated photograph of a bare left hand, cropped by the bottom of the frame so the wrist is out of shot, and the five equipment slots are its fingers. The idea is lifted from the reference almost move for move, and it changes what an inventory is: an item description like "A symbol of love. The ring looks old." only lands because the ring is on a hand.

The five finger positions are not laid out by hand. They are measured off the generated plate: take the top-most opaque row of each column of public/art/hand.png and it has five local minima, one per fingertip, at 0.906, 0.633, 0.386, 0.228 and 0.045 of the width. Those five numbers are the slot positions in src/sim/items.ts, with a comment saying that regenerating the plate means taking them again. The names come from the reference and they are real: thumb, shooting finger, long finger, medicine finger โ€” the ring finger has been the medicine finger since Old English, because it was the one a physician stirred with โ€” and ear finger.

A ring is not drawn as jewellery. At 512 pixels a ring is four pixels of gold and reads as a scratch, so it goes down as a band across the finger: an 11ร—2 bar of paper with an 11ร—1 bar of ink under it, which is what a ring looks like when a halftone is all you have.

The other screen is the map โ€” a hex field on a circular vignette, nine hexes across a 1536-metre continent. Every hex is filled by screenFill, a 4ร—4 ordered dither laid into the buffer at a coverage set by the hex's elevation, so the map is a hypsometric tint made of nothing but dot density. A hex within sight but not yet walked into gets 42% of its coverage and a question mark; one further off gets 16% and no glyph, so the continent has a shape from the first minute and the walk fills it in. The circle is sized so the square continent's corners fall just outside it: you are looking at a square world through a round hole.

Both screens are also why the world is drawn with an ordinary 2D canvas rather than in GL. It costs a texture upload per frame โ€” about 0.4 ms for 512ยฒ โ€” and buys ctx.fillText, which is how the blackletter gets into the buffer before the screen shader rather than on top of it. The type is treated in two ways for the same reason: the display face keeps its anti-aliasing and is left for the shader to break up, and the eight-pixel body face is thresholded to one bit at 0.42 alpha first, so the shader has nothing to break.

Everything on screen goes into the same 512-pixel buffer and through the same shader; a UI layer composited after the screen pass would be the only crisp thing in the frame.

The rot

The only thing the rot does is make the screen worse

rot is one number between 0 and 1. It rises only while the burden is on your back, faster the further you have carried it, and nothing in the world removes it. It is read in five places, and none of them is speed() or the stamina drain: applyLook writes it into six shader uniforms, hand.ts picks which of the burden's four descriptions you are shown, hud.ts decides how many blocks of the burden meter are drawn, and screens.ts uses it twice โ€” once to choose the ending, once to print a percentage on the end screen.

128 โ†’ 76 cellsthe halftone coarsens across the square, so the same picture is printed at a coarser screen
5.6 โ†’ 8.6vignette slope, with its start pulled in from r = 1.26 to 0.96 โ€” the corners eat inward
6 โ†’ 5 levelsat rot 0.53, which is 1/1.9, the top of the ramp stops being reachable at all
blur and grain0.0034 โ†’ 0.0074 and 0.012 โ†’ 0.042; the sharp band shrinks and the paper gets dirtier

The world does not become harder to cross. It becomes harder to read. And because the sharp band is the mechanic, a rotted screen is one you have to walk further into to see anything, which costs stamina, which is the stat that decides how far you get in a day.

The weight is the other half of the arithmetic. The burden is 4.0 kg against a working limit of 6.5, and the fourteen things you can pick up and carry weigh 5.59 kg between them, so the one object you did not choose to bring is most of your capacity before you have found anything. Past the limit the stamina drain goes quadratic: under it an extra kilo costs you a few seconds, over it an extra kilo costs you the afternoon.

The rot also decides how the walk ends. Reach the shore having put the burden down and you get Set Down. Reach it still carrying it with rot above 0.72 and you get Carried In. Reach it carrying it with less than that and you get Turned Back. Three endings, one threshold.

The number that decides how the walk ends is the same number that has been coarsening the print for the quarter of an hour it takes to get there.

The bill

What measuring a look costs

Measuring a look rather than eyeballing it buys certainty about the parts you measured and nothing at all about the parts you did not. Here is the list.

The terrain is not the same construction as the reference's. Its ground is genuinely flat-shaded low-poly geometry: real facets, straight facet edges, a hard tonal break where two planes meet. Bearer's is a heightfield with a quantised Lambert term over it. Rounding the shading into sixths gets facet-like bands out of a smooth surface, and at six tone levels and 512 pixels a still frame holds up โ€” but the edges between bands follow contours of a smooth field, not the edges of triangles, and if you go looking for straight facet boundaries in a Bearer screenshot you will not find them.

The dot shape is the weakest of the measurements. The extracted matrix proves ink grows from the cell centre and proves the corners fill last, and it ranks the four candidate shapes โ€” but all four correlate positively with it, and 0.798 against 0.655 is a preference rather than a proof. The diamond is in there because it comes first. A square shipped for an afternoon on a written description instead, and cost nothing measurable: both shapes are equalised, and the tone histogram did not move between them.

WebGL 2 only.The whole look is one fragment shader over a 512-pixel buffer. There is no fallback that keeps any of it, and the page says so instead of degrading.
Nothing can have a colour.Six greys and a diagonal tint. Any asset that wanted to be identifiable by hue โ€” a flag, a fruit, a wound โ€” has to be identifiable by shape instead.
A generated world is flat.The calibration histogram shows it: 0.5% of a daylight frame on the brightest level against the reference's 2.0%, and it needed a contrast curve anchored below mid grey to manage that.
Blurred fragments are drawn nine times.Nine printed() calls each, sampling, quantising and screening every time. Cheap on a desktop GPU; the reason the buffer is 512 and not 1024.
The plates repeat.Twelve tiles of ground, made seamless by a roll-and-feather pass and hidden under a wide, shallow macro multiply. You should never see the tiling; you can, if you look for it.
The type is not the reference's.Its bitmap face was drawn for it. Bearer uses Silkscreen thresholded to one bit and UnifrakturMaguntia left anti-aliased, which is the same treatment applied to different letterforms.

The debt, stated plainly: the rendering studied here is Dean Moynihan's, in O EMPIRE! WARD OFF THY ROT. The measurements were taken from that game's published store screenshots and the technique was reimplemented from them, not ported. Everything on the other side of the shader โ€” the continent, the burden, the fingers, the endings โ€” is Bearer's own.

A measurement can only settle the question somebody thought to ask, and nothing in the study asked what the ground was made of.

Start walking โ†—

Marcin โ€” creator of vibespire.ai

About

Hi, I'm Marcin.

I build these experiments whenever something sparks my curiosity โ€” a paper, a game, the way grass bends in the wind, the smallest everyday things. Inspiration shows up, and I chase it into a little living world you can open in a browser.

vibespire is where those experiments live. Poke at them, break them, read how they're made โ€” and if something sparks an idea for you too, say hello.