How it was built

A city drawn with letters

Una ciudad dibujada con letras

CHOLINTIMO64 is a walkable Santa Cruz de la Sierra rendered entirely in characters — one HTML file, no engine, no models, no textures, no images, no audio files. Every frame casts one ray per screen column to work out what is visible, how far away it is, and what you would walk into.

None of that was a stylistic decision first. It was a constraint, and the constraint produced the technique. This page shows the parts worth showing.

The constraint

The machine had no fast path, so the renderer had to stop asking for one

This was built on a 2017 Intel MacBook Pro. On that machine there is no GPU fast path for canvas, and drawImage costs roughly 4–7 µs per call. The obvious way to draw a grid of characters is one blit per cell. At the working grid size that measured 75 ms per frame — about thirteen frames a second, before any game logic runs at all.

So the renderer stopped drawing characters. Every (glyph × colour) pair is rasterised once into a flat Uint32Array bank at load. A frame is then assembled as raw pixels and handed to the canvas in a single putImageData. Same grid, same output:

75 ms
one drawImage per cell
5 ms
baked bank · one putImageData

Everything else in the engine follows from that one decision.

The idea, running

One ray per column

The diagram below is a working miniature of the same engine — the same DDA march, the same baked glyph bank, the same single putImageData. On the left, the map seen from above: the camera and the rays it fires. On the right, exactly what those rays produce. Drag either panel to look around.

Map, from above

What the rays draw

96 rays · 2 688 cells · per frame Drag to look around

The frame, start to finish

The grid

The city is a flat array of cell types, generated once from a seeded PRNG. No geometry, no mesh.

A ray per column

For each of the screen's columns, a direction is built from the camera vector plus the camera plane.

The DDA march

The ray steps cell by cell along the grid until it lands on something that is not open air.

Perpendicular distance

Because the ray came from the camera plane, the distance is already fisheye-free. There is no correction step.

Glyph and colour

Distance and wall side pick a character off a density ramp and an index into the baked colour bank.

One push

Unchanged cells are skipped, changed cells are copied as pixels, and the whole framebuffer goes out in a single call.

The code

Four excerpts that carry the weight

All four are verbatim from the shipped file. Nothing here is a simplified illustration.

present()index.html : 245
function present(){
  const n=COLS*ROWS, W=cv.width;
  for(let i=0;i<n;i++){
    const g=buf[i],c=col[i];
    if(g===pbuf[i]&&c===pcol[i])continue;      // untouched cells cost nothing
    pbuf[i]=g; pcol[i]=c;
    let s=(c*NG+gIndex[g])*TS;
    let d=(((i/COLS)|0)*CH)*W + (i%COLS)*CW;
    for(let y=0;y<CH;y++,d+=W){
      for(let x=0;x<CW;x++)px[d+x]=bank[s+x];
      s+=CW;
    }
  }
  ctx.putImageData(img,0,0);
}
The whole renderer. bank holds every glyph in every colour, pre-rasterised; pbuf/pcol hold last frame, so a cell that did not change costs one comparison. Standing still in a doorway is nearly free.
castRay()index.html : 631
/* one DDA march; returns perpendicular distance and what was hit */
function castRay(ox,oy,dx,dy,maxD){
  let mx=ox|0, my=oy|0;
  const ddx=Math.abs(1/(dx||1e-9)), ddy=Math.abs(1/(dy||1e-9));
  let sx,sy,sdx,sdy;
  if(dx<0){sx=-1;sdx=(ox-mx)*ddx;} else {sx=1;sdx=(mx+1-ox)*ddx;}
  if(dy<0){sy=-1;sdy=(oy-my)*ddy;} else {sy=1;sdy=(my+1-oy)*ddy;}
  let side=0, d=0, guard=0;
  while(guard++<256){
    if(sdx<sdy){sdx+=ddx;mx+=sx;side=0;d=sdx-ddx;}
    else {sdy+=ddy;my+=sy;side=1;d=sdy-ddy;}
    if(!inMap(mx,my))return {d:maxD,t:FACADE,side,mx,my,hit:false};
    const t=map[my*MW+mx];
    if(t!==OPEN) return {d:Math.max(0.02,d),t,side,mx,my,hit:true};
    if(d>maxD)break;
  }
  return {d:maxD,t:0,side:0,mx,my,hit:false};
}
Seventeen lines, and they do three jobs. Fired once per screen column they draw the world. Their distances fill the z-buffer, so sprite occlusion is free. Fired once backwards from the player, the same function pulls the third-person camera in when a wall is behind you.
blip()index.html : 1067
function blip(freq,dur,type,vol,slideTo){
  if(!AC||!soundOn)return;
  const t=AC.currentTime;
  const o=AC.createOscillator(), g=AC.createGain();
  o.type=type||"square"; o.frequency.setValueAtTime(freq,t);
  if(slideTo)o.frequency.exponentialRampToValueAtTime(Math.max(20,slideTo),t+dur);
  g.gain.setValueAtTime(0.0001,t);
  g.gain.exponentialRampToValueAtTime(vol||0.5,t+0.008);
  g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
  o.connect(g); g.connect(MASTER); o.start(t); o.stop(t+dur+0.02);
}
The soundtrack is synthesised at runtime — square leads, a triangle bass, a noise snare built from a generated buffer. There are no audio files, which is why the page stays one self-contained document and why nothing in it has to be cleared for rights.
frame() — adaptive resolutionindex.html : 1400
// lose resolution rather than frames — with hysteresis and a cooldown,
// because re-baking the glyph bank is not free
if(cool>0)cool--;
else if(ftAvg>26&&FS<24){FS+=2;layout();forceRedraw();cool=6;}
else if(ftAvg<9&&FS>10){FS-=1;layout();forceRedraw();cool=6;}
A slow machine gets a coarser city, never a stuttering one. The cell size grows, the grid shrinks, the frame rate holds. The cooldown exists because the fix itself costs something, and a naive version would oscillate.

What it weighs

It works offline. There is nothing to install, and no request leaves the page after it loads — so saving the file to a desktop is, already, the offline build.

The honest part

Three bugs that changed the design

Each of these is still in the source as a comment, because the comment is the thing that stops it happening again.

Six-metre pedestrians

Prop sizes were being written in grid cells, and a cell is 3.5 metres. The people came out taller than the shopfronts.

→ Every size is now written in metres and converted by one helper.

Characters that drew nothing

Two block-drawing glyphs were missing from the allowed set. They rendered as blank, which looks like a layout bug, not a missing character.

→ Anything outside the set now draws a loud ?.

A street with no street

The pass that scatters shopfronts could close off the only road out of the plaza — the route the whole game depends on.

→ A breadth-first search asserts the path at load and complains.

Why this is on an investor site

The same discipline, on the things that bill

A walkable ASCII plaza is not a product. It is a legible sample of how the work gets done: measure first, let the constraint choose the technique, keep the artefact small enough that one person can hold all of it, and write down the mistakes where the next person will hit them.

The systems that carry revenue at Dearborn — the building administration, the laundry vertical riding the same rails — are built the same way and are harder to show. This one you can open in a phone browser in four seconds.

The plaza is also a book

CHOLINTIMO64 is set inside a feature screenplay, and the printed volume carries the whole script and the two hundred storyboard plates drawn for it. Its last page hands the reader back here — the same plaza, on paper and then walkable.

See the book → USD 19.99 · 8 × 10 in