How it was built
Cómo se hizo
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.
CHOLINTIMO64 es una Santa Cruz de la Sierra caminable dibujada enteramente con caracteres — un solo archivo HTML, sin motor, sin modelos, sin texturas, sin imágenes y sin archivos de audio. Cada fotograma tira un rayo por columna de pantalla para saber qué se ve, a qué distancia, y contra qué se choca.
Nada de eso empezó como una decisión estética. Empezó como una restricción, y la restricción produjo la técnica. Esta página muestra las partes que vale la pena mostrar.
The constraint
La restricción
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:
Esto se construyó en una MacBook Pro de 2017 con procesador Intel. En esa máquina no hay ruta
rápida por GPU para canvas, y drawImage cuesta unos 4–7 µs por llamada. La forma
obvia de dibujar una grilla de caracteres es un blit por celda. Al tamaño de grilla real eso midió
75 ms por fotograma — unos trece cuadros por segundo, antes de correr una sola línea
de lógica.
Así que el renderizador dejó de dibujar caracteres. Cada par (glifo × color) se
rasteriza una sola vez, al cargar, dentro de un banco plano Uint32Array. Después el
fotograma se arma como píxeles crudos y se entrega al canvas en un único
putImageData. Misma grilla, misma salida:
Everything else in the engine follows from that one decision.
Todo lo demás en el motor se desprende de esa sola decisión.
The idea, running
La idea, funcionando
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.
El diagrama de abajo es una miniatura funcional del mismo motor — la misma marcha DDA, el
mismo banco de glifos horneado, el mismo putImageData único. A la izquierda, el mapa
visto desde arriba: la cámara y los rayos que dispara. A la derecha, exactamente lo que esos
rayos producen. Arrastrá cualquiera de los dos paneles para mirar alrededor.
Map, from above
El mapa, desde arriba
What the rays draw
Lo que dibujan los rayos
The frame, start to finish
El fotograma, de principio a fin
The city is a flat array of cell types, generated once from a seeded PRNG. No geometry, no mesh.
For each of the screen's columns, a direction is built from the camera vector plus the camera plane.
The ray steps cell by cell along the grid until it lands on something that is not open air.
Because the ray came from the camera plane, the distance is already fisheye-free. There is no correction step.
Distance and wall side pick a character off a density ramp and an index into the baked colour bank.
Unchanged cells are skipped, changed cells are copied as pixels, and the whole framebuffer goes out in a single call.
La ciudad es un arreglo plano de tipos de celda, generado una vez desde un PRNG con semilla. Sin geometría, sin malla.
Para cada columna de la pantalla se arma una dirección con el vector de cámara más el plano de cámara.
El rayo avanza celda por celda sobre la grilla hasta caer en algo que no sea aire.
Como el rayo salió del plano de cámara, la distancia ya viene sin ojo de pez. No hay paso de corrección.
La distancia y el lado del muro eligen un carácter de una rampa de densidad y un índice del banco de color.
Las celdas iguales se saltan, las que cambiaron se copian como píxeles, y el framebuffer entero sale en una sola llamada.
The code
El código
All four are verbatim from the shipped file. Nothing here is a simplified illustration.
Los cuatro son textuales del archivo publicado. Nada acá es una ilustración simplificada.
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);
}
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.bank guarda cada glifo
en cada color, ya rasterizado; pbuf/pcol guardan el fotograma anterior, así
que una celda que no cambió cuesta una comparación. Quedarse quieto en una puerta es casi
gratis./* 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};
}
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);
}
// 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;}
What it weighs
Lo que pesa
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.
Funciona sin conexión. No hay nada que instalar, y no sale ni un pedido de la página después de cargar — así que guardar el archivo en el escritorio ya es, de por sí, la versión offline.
The honest part
La parte honesta
Each of these is still in the source as a comment, because the comment is the thing that stops it happening again.
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.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?.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.Cada uno sigue en el código como comentario, porque el comentario es lo que evita que vuelva a pasar.
Los tamaños se escribían en celdas de grilla, y una celda son 3,5 metros. La gente salía más alta que las tiendas.
→ Ahora todo se escribe en metros y lo convierte una sola función.Faltaban dos glifos de bloque en el conjunto permitido. Salían en blanco, que parece un error de maquetación y no un carácter que falta.
→ Lo que esté fuera del conjunto dibuja un? bien visible.La pasada que reparte tiendas podía tapar el único camino que sale de la plaza — la ruta de la que depende el juego entero.
→ Una búsqueda en anchura verifica el paso al cargar y se queja.Why this is on an investor site
Por qué esto está en un sitio para inversores
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.
Una plaza ASCII caminable no es un producto. Es una muestra legible de cómo se trabaja: medir primero, dejar que la restricción elija la técnica, mantener la pieza lo bastante chica como para que una sola persona la sostenga entera, y dejar escritos los errores donde el que venga se los va a llevar puestos.
Los sistemas que facturan en Dearborn —la administración de edificios, la vertical de lavandería montada sobre los mismos rieles— están hechos igual y son más difíciles de mostrar. Éste se abre en el navegador de un teléfono en cuatro segundos.
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 inCHOLINTIMO64 transcurre dentro de un guion de largometraje, y el volumen impreso trae el guion completo y las doscientas láminas de storyboard dibujadas para él. Su última página devuelve al lector hasta acá — la misma plaza, en papel y después caminable.
Ver el libro → USD 19,99 · 8 × 10 pulgadas