<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SPixs</title>
    <description>The latest articles on DEV Community by SPixs (@spixs).</description>
    <link>https://dev.to/spixs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4153126%2F4fef6acb-5355-4346-873a-6106804c04f7.png</url>
      <title>DEV Community: SPixs</title>
      <link>https://dev.to/spixs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/spixs"/>
    <language>en</language>
    <item>
      <title>How I fit a bubble shooter into 3,810 bytes of C64 assembly</title>
      <dc:creator>SPixs</dc:creator>
      <pubDate>Wed, 30 Sep 2026 21:01:38 +0000</pubDate>
      <link>https://dev.to/spixs/how-i-fit-a-bubble-shooter-into-3810-bytes-of-c64-assembly-2oci</link>
      <guid>https://dev.to/spixs/how-i-fit-a-bubble-shooter-into-3810-bytes-of-c64-assembly-2oci</guid>
      <description>&lt;p&gt;Over the winter of 2020–21, Phoenix Ware and The RVG Squad ran &lt;strong&gt;The C64 'Cassette 50' Charity Competition&lt;/strong&gt;, a tribute to &lt;em&gt;Cassette 50&lt;/em&gt;, Cascade's infamous 1983 compilation of fifty games. The rule was simple and brutal: &lt;strong&gt;every game had to fit in 4 KB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My entry was &lt;strong&gt;TinyBubbles&lt;/strong&gt;, a &lt;em&gt;Puzzle Bobble&lt;/em&gt;-style bubble shooter written in 6502 assembly. It won the competition. The whole game is a &lt;strong&gt;3,810-byte PRG&lt;/strong&gt;, with 286 bytes to spare.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9le1oc5lntysmn7yb657.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9le1oc5lntysmn7yb657.gif" alt="TinyBubbles gameplay" width="704" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can &lt;strong&gt;&lt;a href="https://spixs.github.io/TinyBubbles/" rel="noopener noreferrer"&gt;play it in your browser&lt;/a&gt;&lt;/strong&gt;, and the full source is on &lt;strong&gt;&lt;a href="https://github.com/SPixs/TinyBubbles" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt;. Here is how it fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The budget: everything below $1000
&lt;/h2&gt;

&lt;p&gt;The PRG loads from &lt;code&gt;$0120&lt;/code&gt; to &lt;code&gt;$0FFF&lt;/code&gt;. That is the first 4 KB of the C64's address space, the part most programs leave alone because the stack, the system vectors and the screen live there. TinyBubbles moves in anyway and squeezes into the nooks:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fynaonr7vj3xrbnh9ghrn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fynaonr7vj3xrbnh9ghrn.png" alt="Memory map of TinyBubbles" width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screen layout (&lt;code&gt;map.bin&lt;/code&gt;) is loaded straight into screen RAM at &lt;code&gt;$0400&lt;/code&gt;, and the custom charset goes right after it at &lt;code&gt;$0800&lt;/code&gt;. What's left over is code, tables and a lot of creative recycling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting without RUN
&lt;/h2&gt;

&lt;p&gt;Loading at &lt;code&gt;$0120&lt;/code&gt; means the file overwrites the &lt;strong&gt;stack&lt;/strong&gt; while it is being loaded. Instead of fighting that, the game exploits it. Two bytes placed at &lt;code&gt;$01F8&lt;/code&gt; overwrite the return address that the &lt;code&gt;LOAD&lt;/code&gt; command will pull from the stack when the Kernal has finished loading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Modify the stack so that the load routine branches to our entry point after
// it has complete its loading.
*=$1f8 "Stack override"
    .byte &amp;lt;[Entry-1], &amp;gt;[Entry-1]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;LOAD&lt;/code&gt; finishes, its &lt;code&gt;RTS&lt;/code&gt; "returns" into the game. You type &lt;code&gt;LOAD"*",8,1&lt;/code&gt; and the game starts. No &lt;code&gt;RUN&lt;/code&gt;, and no BASIC stub wasting bytes.&lt;/p&gt;

&lt;p&gt;There's a catch: the file also runs through pages 2 and 3, where the Kernal keeps its vectors (IRQ, NMI, the I/O routines, the keyboard decoder). So the PRG carries their &lt;strong&gt;default values&lt;/strong&gt; at the right addresses. The Kernal keeps working until the very last byte is loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  A one-instruction main loop
&lt;/h2&gt;

&lt;p&gt;Once everything is set up, the main loop is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mainLoop: {
    jmp *
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole game (joystick, trajectory, collisions, popping, score, sound) runs in a &lt;strong&gt;raster interrupt&lt;/strong&gt; at line 252, once per frame. The IRQ handler restores the registers itself and returns with &lt;code&gt;RTI&lt;/code&gt;, so the Kernal's own interrupt routine (keyboard scan, cursor blink) never runs again. Nothing wastes cycles behind the game's back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the hardware do the work
&lt;/h2&gt;

&lt;p&gt;In 4 KB, every routine you don't write is a win. TinyBubbles leans on the chips as much as it can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collisions come from the VIC-II.&lt;/strong&gt; There is no distance check between the flying bubble and the grid. The bubble is a sprite, the grid is made of characters, and the VIC-II sets a bit in &lt;code&gt;$D01F&lt;/code&gt; the moment a sprite pixel touches a background pixel. The game just reads the register and snaps the bubble to the nearest grid cell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Random numbers come from the SID.&lt;/strong&gt; There is no pseudo-random generator. SID voice 3 runs white noise at maximum frequency with its gate closed (so it makes no sound), and reading &lt;code&gt;$D41B&lt;/code&gt; returns the oscillator output. That picks the colour of the next bubble and the pitch of the "pop" sound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bubbles are characters, not sprites.&lt;/strong&gt; A bubble on the board is four multicolour characters (&lt;code&gt;$0A&lt;/code&gt;–&lt;code&gt;$0D&lt;/code&gt;). Every bubble uses the same four characters, and colour RAM tints each one. Only the bubble you shoot, the crosshair and the two halves of the launcher are sprites.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxao0xh7dqlulfle83ax.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxao0xh7dqlulfle83ax.png" alt="Charset and sprites" width="648" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The charset has 48 characters: bricks, frames, digits and just the letters needed on screen. Even that was trimmed: the "O" in &lt;code&gt;SCORE&lt;/code&gt; and the "I" in &lt;code&gt;HISCORE&lt;/code&gt; are actually the digits &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hexagonal grid and a recursion on a leash
&lt;/h2&gt;

&lt;p&gt;The board is an &lt;strong&gt;8 × 10 grid&lt;/strong&gt;, staggered like a honeycomb: every other row is shifted by half a bubble. It lives in zero page, one byte per cell: &lt;code&gt;$80&lt;/code&gt; means empty, otherwise the low bits hold the colour, and two more bits mark cells during traversals.&lt;/p&gt;

&lt;p&gt;Two flood fills do the gameplay:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;After a shot, find all connected bubbles of the same colour. Three or more pop.&lt;/li&gt;
&lt;li&gt;Then find every bubble still connected to the ceiling. The rest falls.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both share the same "visit the six neighbours" routine through an indirect jump, so the neighbour logic exists only once.&lt;/p&gt;

&lt;p&gt;Recursion on a 6502 means the stack, and the stack is tiny. During initialisation it is limited to 32 bytes. Once the game is running, it gets the whole of page 1, including the space where the initialisation code used to be, since that code will never run again. And just in case, the recursion checks how much stack is left before going deeper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;recurseFunction: {
    tsx
    cpx #$06
    bcs !+
    rts // not enough space in stack... stop recurce !

!:
    jmp (functionVector)
functionVector:
    .word $00
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Aiming and bouncing
&lt;/h2&gt;

&lt;p&gt;The crosshair slides along a &lt;strong&gt;sine arc&lt;/strong&gt;. The table isn't typed in by hand, the assembler computes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SINE_SIGHT_SCREEN_LOOKUP: {
.fill 32,round(26*sin(toRadians(i*192/32)))
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you fire, the vector from the bubble to the crosshair becomes a &lt;strong&gt;fixed-point&lt;/strong&gt; velocity: 16 bits, shifted left by 5 for sub-pixel precision. Each frame adds it to the position. Hitting a side wall just flips the sign of the horizontal speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small savings
&lt;/h2&gt;

&lt;p&gt;A few more tricks, each worth a handful of bytes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Levels are packed two bubbles per byte&lt;/strong&gt;, one nibble each. A level is 24 bytes, unpacked into the grid when it loads. There are three levels, and they loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The score never gets converted for display.&lt;/strong&gt; It is stored one decimal digit per byte, and since the charset's codes 0 to 9 are the digits, it is copied to screen RAM as-is. The level counter is even incremented directly in screen RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sprite images live in the tape buffer area&lt;/strong&gt; (&lt;code&gt;$0380&lt;/code&gt; and &lt;code&gt;$03C0&lt;/code&gt;), which nobody needs once the game has loaded from disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;InitWindow&lt;/code&gt; modifies its own code&lt;/strong&gt;: it rewrites the operand of its &lt;code&gt;sta&lt;/code&gt; instruction to step down one screen row at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sound
&lt;/h2&gt;

&lt;p&gt;Three effects, all driven directly on the SID registers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;"pop"&lt;/strong&gt;: a triangle wave on voice 1 at a random pitch, taken from the noise oscillator;&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;explosion&lt;/strong&gt;: noise on voice 2;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;bell&lt;/strong&gt; at the end of each level: voice 1 ring-modulated by voice 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Found again on a USB stick
&lt;/h2&gt;

&lt;p&gt;I recently found the sources on an old USB stick. With KickAssembler 5.16, they rebuild &lt;strong&gt;byte for byte&lt;/strong&gt; into the last build on the stick, from February 2021. The project is now open source under the GPL, with a detailed README and a browser version.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;▶ &lt;strong&gt;Play it in your browser:&lt;/strong&gt; &lt;a href="https://spixs.github.io/TinyBubbles/" rel="noopener noreferrer"&gt;https://spixs.github.io/TinyBubbles/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/SPixs/TinyBubbles" rel="noopener noreferrer"&gt;https://github.com/SPixs/TinyBubbles&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;itch.io:&lt;/strong&gt; &lt;a href="https://spixs.itch.io/tinybubbles" rel="noopener noreferrer"&gt;https://spixs.itch.io/tinybubbles&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you still have a real C64, &lt;code&gt;LOAD"*",8,1&lt;/code&gt; is all you need.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was written with the help of an AI assistant, then checked against the game's source code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>c64</category>
      <category>retrocomputing</category>
      <category>assembly</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
