CS5160 · Project 1 · Sept 2026
Pinhole camera, rasterized display, aka a field goal game on a fake 320×200 screen
For Project 1 I made a field goal game. You're the kicker, so you line it up, check the wind, time the power meter, and try not to shank it in front of the whole stadium. It's all plain JavaScript on an HTML canvas, no WebGL and no libraries, so everything on screen is something my code had to figure out how to draw. There are three renderers and you can flip between them with the 1, 2 and 3 keys: a high-res wireframe, a 320×200 "old computer" display with my own line drawing, and a filled-triangle version with a depth buffer.

01 · Design
What I wanted it to look like and how it should play, before I wrote any code.
Friday night lights. Dark sky with stars, a striped field with yard lines and end zones, yellow goal posts, bleachers packed on both sides, light towers in the corners, and both teams lined up for the kick. It had to look decent as thin lines at 1600×1000 and still obviously be a football field once it's chunky 5×5 pixels at 320×200.
You're the kicker and also the camera. The camera can't rotate (that's a project rule), so you aim by sliding left or right and pick your distance by moving up or back. Space kicks. The ball flies on an actual projectile arc, wind pushes it sideways, and the game tells you how it went: good, wide left, wide right, under the crossbar or short. Make it and your team starts jumping and the crowd goes nuts. R resets everything.
02 · Features & Controls
Open the live app, click on the canvas so it picks up your keys, and go.
| Key | What it does |
|---|---|
| ← → | Move the camera left / right (x) |
| ↑ ↓ | Move the camera forward / back (z). This changes the kick distance. |
| W S | Raise / lower the camera (y) for a higher view |
| A D | Aim the kick left / right to fight the wind |
| Space | Hold to charge the power meter, release to kick. Above 90% power the kick can shank. |
| F | Ball-cam: the camera follows the ball in flight (translation only) |
| C | Cycle team color schemes |
| R | Reset the camera, objects and score to the initial scene |
| 1 2 3 | Switch renderer: wireframe 1600×1000 / raster lines 320×200 / filled triangles 320×200 |
| G | Toggle the pixel-grid outline (modes 2–3) |
| Z | Show the depth buffer as grayscale (mode 3) |
Scoring. A make is 3 points, or 5 if it's from 50+. The HUD keeps your score, makes/attempts, streak, best streak, longest make, wind and distance. Clip the upright and you get a DOINK!

lineTo at 1600×1000.






03 · Implementation
Here's where the stuff from class shows up in the code, roughly in the order it runs every frame.
Every object type is a model. It's a list of points centered at the origin, a list of edges (pairs of points) for
the wireframe modes, and a list of triangles (three points each) for the filled mode. Each edge and triangle also has a color tag like
"jersey" or "helmet". Most of the models are stacks of boxes from one helper function, and each box is
8 vertices, 12 edges and 12 triangles (2 per face).
// Object types: field segment, yard line, goal post, player, football, // cube (the Level 0 cube, used as sideline coolers), pylon, bleachers, light tower, ball shadow const PLAYER = newModel("player"); addBox(PLAYER, 0, 0.45 - PL_CY, 0, 0.5, 0.9, 0.3, "pants"); // legs addBox(PLAYER, 0, 1.3 - PL_CY, 0, 0.8, 0.8, 0.4, "jersey"); // torso + shoulder pads addBox(PLAYER, 0, 1.87 - PL_CY, 0, 0.35, 0.35, 0.35, "helmet"); // helmet
An instance is a model plus a position, a scale and its colors. To put a vertex in the world you scale it and then move it. Then to get it relative to the camera you just subtract the camera's position. Since the camera never rotates, that subtraction is literally the whole view transform.
function toCamera(v, inst) {
return {
x: v[0] * inst.scale + inst.pos.x - camera.x,
y: v[1] * inst.scale + inst.pos.y - camera.y,
z: v[2] * inst.scale + inst.pos.z - camera.z,
};
}
The camera looks down +z. Using similar triangles, a point at (x, y, z) in front of the camera lands on the image at x/z and y/z.
Multiply by a focal length F (I get it from a 70° field of view) and that's in pixels. Then shift by half the screen
so (0,0) is the middle, and flip y because screen rows count downward. The exact same function works for both screens; only
F and the size change (1600×1000 vs 320×200).
Stuff farther away has a bigger z, so it gets divided down and looks smaller. That's the whole reason perspective works, and it's why the goal posts look tiny from 50 yards and the field lines squeeze together toward the horizon.
Dividing by z falls apart once z hits 0 or goes negative. Anything behind the camera flips upside down and shoots off the screen. So before projecting, every edge gets clipped against a near plane at z = 0.3. If both ends are behind it, the edge is skipped. If only one end is, that end gets moved up to where the edge crosses the plane. Triangles do the same thing (Sutherland-Hodgman clipping), which can turn a triangle into a 4-sided shape that gets split back into 2 triangles. I also added basic collision so you can't walk the camera into a player or a goal post.
The fake screen is a 320 × 200 2D array of colors that starts out filled with the background color. Instead of
ctx.lineTo, each edge gets drawn with the incremental line algorithm from class. If the slope is
between -1 and 1, it steps one pixel at a time across and adds the slope m to the height each step. If the line is steeper, it flips
that around and steps down instead, adding 1/m, otherwise steep lines come out as dotted gaps. It samples at the center of each
pixel. Lines also get clipped to the screen first (Liang-Barsky) so it doesn't loop over thousands of pixels that are off screen.
if (Math.abs(dx) >= Math.abs(dy)) { // shallow: step in u
const m = dy / dx;
let v = y0 + m * (uStart + 0.5 - x0);
for (let u = uStart; u <= uEnd; u++) {
setPixelColor(u, Math.floor(v), color);
v += m; // incremental step
}
} else { /* steep: same idea, step in v and add 1/m to u */ }
Once all the edges are in the array, the draw step paints every pixel as a 5×5 rectangle on the real 1600×1000 canvas. Press G to turn on the grid so you can see each pixel.
For each triangle, it projects the 3 corners, grabs their bounding box (min and max u and v, kept on screen), and checks the center of every pixel in that box. The check uses barycentric coordinates from areas. Each weight is the area of the little triangle opposite a corner divided by the area of the whole triangle. If all 3 weights are 0 or more, the pixel is inside. That same signed-area formula is also the "half-plane" test from class, and dividing by the signed area means it works no matter which way the triangle's points go around.
const area = edgeFn(p0, p1, p2); const w0 = edgeFn(p1, p2, P) / area; // weight of p0 const w1 = edgeFn(p2, p0, P) / area; // weight of p1 const w2 = 1 - w0 - w1; // weights sum to 1 if (w0 < 0 || w1 < 0 || w2 < 0) continue; // outside the triangle
There are hundreds of triangles overlapping, so the painter's algorithm would mean sorting all of them, and it still breaks when triangles overlap weird. So I used a z-buffer instead, which is one depth value per pixel that resets every frame. The same barycentric weights from the coverage test also blend the depth across the triangle. It blends 1/z instead of z, because after the perspective divide 1/z is what changes evenly across the screen, so the depths come out right. A pixel only gets drawn if it's closer than what's already stored, which means the triangles can be drawn in any order. Press Z to see the buffer.
Every surface gets its color from its instance, like team jerseys, the turf stripes or the yellow posts. Without shading every box would look like a flat sticker, so each triangle gets a normal from the cross product of two of its edges, and its brightness depends on the dot product of that normal with a fixed light direction. That's basic flat (per-triangle) Lambert shading.
04 · Future work
05 · Demo
Me walking through the three renderers, the controls, and the graphics concepts.
Can't see it? Watch on YouTube
06 · AI use
I came up with the concept and design and built the graphics side myself, including my own rasterizer, working from the class slides. I used Claude (Anthropic) as a coding assistant: it wrote the larger portion of the rest of the code, like the gameplay (kicking, wind, scoring, ball-cam), the stadium models, and the HUD. I tested everything, directed the changes, and swapped in my own rasterizer code, so I can explain how the pipeline works.
07 · Links